diff --git a/.gitignore b/.gitignore index d446339..178fe52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,84 @@ -.* -!.gitignore -/.idea +# OS .DS_Store +.AppleDouble +.LSOverride + +# IDEs / Editors +.idea/ +.vscode/ +*.iml +.run/ + +# Python __pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ +.mypy_cache/ +.pytype/ +.pyre/ +.ruff_cache/ +.hypothesis/ +.ipynb_checkpoints + +# Virtual envs / tooling +.venv/ +venv/ +env/ +ENV/ +.python-version +pip-log.txt +pip-delete-this-directory.txt + +# Coverage / reports / caches +.coverage +.coverage.* +.cache/ +htmlcov/ +coverage.xml +*.cover +*.py,cover +nosetests.xml +pytestdebug.log + +# Logs / temp +logs/ +*.log +*.log.* +*.tmp +tmp/ +temp/ + +# Jupyter *.ipynb -/examples -/starter_model -/mesa + +# C extensions +*.so + +# Node / JS +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.webpack-cache/ +.parcel-cache/ + +# MkDocs site/ -sorted-out-tests -/benchmarks -/notes -/docs/work_in_progress_exclude -# short term: -Dockerfile -docker-compose.yml -Singularity.def -/app.py -/main.py -templates -/docs/images/CI-images -.coverage* -*.cache -ai_info.txt -convert_docstrings.py -TODO.txt -democracy_sim/simulation_output +docs/images/ + +# Project data and artifacts +data/ +!data/.gitkeep + +# Configs +configs/**/*.yaml +!configs/**/default.yaml + +# Local env and secrets +.env +.env.* +*.env +*.secrets +secrets/ diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..2e4e506 --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,46 @@ +# configs.yaml +model: + # Elections + election_costs: 1 + max_reward: 1 + election_impact_on_mutation: 1.8 + mu: 0.05 + rule_idx: 1 + distance_idx: 1 + + # Model parameters + num_agents: 800 + common_assets: 40000 + num_colors: 3 + color_patches_steps: 3 + patch_power: 1.0 + heterogeneity: 0.3 # color_heterogeneity + known_cells: 10 + + # Voting Agents + num_personalities: 4 + + # Grid parameters + height: 100 # (grid_rows) + width: 80 # (grid_cols) + + # Voting Areas + num_areas: 16 + av_area_height: 25 + av_area_width: 20 + area_size_variance: 0.0 + +# Visualization parameters +visualization: + # Grid parameters + cell_size: 10 + draw_borders: true + # Statistics and Views + show_area_stats: true + +simulation: + runs: 3 # number of independent seeds + num_steps: 3 + processes: 8 # ≤ CPU cores + store_grid: true # set true only if you really need full grids + grid_interval: 1 # save every 10th step if grids enabled diff --git a/configs/default.yaml b/configs/default.yaml new file mode 100644 index 0000000..c972e8b --- /dev/null +++ b/configs/default.yaml @@ -0,0 +1,42 @@ +model: + # Elections + election_costs: 1 + max_reward: 2 + election_impact_on_mutation: 1.8 + mu: 0.05 + rule_idx: 1 + distance_idx: 1 + + # Model parameters + num_agents: 800 + common_assets: 40000 + num_colors: 3 + color_patches_steps: 3 + patch_power: 1.0 + heterogeneity: 0.3 + known_cells: 10 + + # Voting Agents + num_personalities: 4 + + # Grid parameters + height: 100 + width: 80 + + # Voting Areas + num_areas: 16 + av_area_height: 25 + av_area_width: 20 + area_size_variance: 0.0 + +visualization: + cell_size: 10 + draw_borders: true + show_area_stats: true + +simulation: + runs: 3 + num_steps: 3 + processes: 8 + store_grid: true + grid_interval: 1 diff --git a/configs/toy.yaml b/configs/toy.yaml new file mode 100644 index 0000000..b6d292d --- /dev/null +++ b/configs/toy.yaml @@ -0,0 +1,46 @@ +# To use this configuration, set: CONFIG_FILE=toy.yaml +model: + # Elections + election_costs: 1 + max_reward: 2 + election_impact_on_mutation: 1.8 + mu: 0.05 + rule_idx: 1 + distance_idx: 1 + + # Model parameters + num_agents: 5 + common_assets: 5000 + num_colors: 3 + color_patches_steps: 3 + patch_power: 1.0 + heterogeneity: 0.3 # color_heterogeneity + known_cells: 5 + + # Voting Agents + num_personalities: 3 + + # Grid parameters + height: 20 # (grid_rows) + width: 10 # (grid_cols) + + # Voting Areas + num_areas: 2 + av_area_height: 10 + av_area_width: 10 + area_size_variance: 0.0 + +# Visualization parameters +visualization: + # Grid parameters + cell_size: 30 + draw_borders: true + # Statistics and Views + show_area_stats: true + +simulation: + runs: 3 # number of independent seeds + num_steps: 3 + processes: 8 # ≤ CPU cores + store_grid: true # set true only if full grid is needed + grid_interval: 1 # save every i'th step if grids enabled diff --git a/democracy_sim/app.py b/democracy_sim/app.py deleted file mode 100644 index f726047..0000000 --- a/democracy_sim/app.py +++ /dev/null @@ -1,86 +0,0 @@ -from mesa.experimental import JupyterViz, make_text, Slider -import solara -from model_setup import * -# Data visualization tools. -from matplotlib.figure import Figure - - -def get_agents_assets(model: ParticipationModel): - """ - Display a text count of how many happy agents there are. - """ - all_assets = list() - # Store the results - for agent in model.voting_agents: - all_assets.append(agent.assets) - return f"Agents wealth: {all_assets}" - - -def agent_portrayal(agent: VoteAgent): - # Construct and return the portrayal dictionary - portrayal = { - "size": agent.assets, - "color": "tab:orange", - } - return portrayal - - -def space_drawer(model, agent_portrayal): - fig = Figure(figsize=(8, 5), dpi=100) - ax = fig.subplots() - - # Set plot limits and aspect - ax.set_xlim(0, model.grid.width) - ax.set_ylim(0, model.grid.height) - ax.set_aspect("equal") - ax.invert_yaxis() # Match grid's origin - - fig.tight_layout() - - return solara.FigureMatplotlib(fig) - - -model_params = { - "height": grid_rows, - "width": grid_cols, - "draw_borders": False, - "num_agents": Slider("# Agents", 200, 10, 9999999, 10), - "num_colors": Slider("# Colors", 4, 2, 100, 1), - "color_adj_steps": Slider("# Color adjustment steps", 5, 0, 9, 1), - "heterogeneity": Slider("Color-heterogeneity factor", color_heterogeneity, 0.0, 0.9, 0.1), - "num_areas": Slider("# Areas", num_areas, 4, min(grid_cols, grid_rows)//2, 1), - "av_area_height": Slider("Av. Area Height", area_height, 2, grid_rows//2, 1), - "av_area_width": Slider("Av. Area Width", area_width, 2, grid_cols//2, 1), - "area_size_variance": Slider("Area Size Variance", area_var, 0.0, 1.0, 0.1), -} - - -def agent_portrayal(agent): - portrayal = participation_draw(agent) - if portrayal is None: - return {} - else: - return portrayal - -def agent_portrayal(agent): - portrayal = { - "Shape": "circle", - "Color": "red", - "Filled": "true", - "Layer": 0, - "r": 0.5, - } - return portrayal - -grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500) - - -page = JupyterViz( - ParticipationModel, - model_params, - #measures=["wealth", make_text(get_agents_assets),], - agent_portrayal=agent_portrayal, - #agent_portrayal=participation_draw, - #space_drawer=space_drawer, -) -page # noqa diff --git a/democracy_sim/model_setup.py b/democracy_sim/model_setup.py deleted file mode 100644 index 29348d1..0000000 --- a/democracy_sim/model_setup.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -This file handles the definition of the canvas and model parameters. -""" -from typing import TYPE_CHECKING, cast -from mesa.visualization.modules import ChartModule -from democracy_sim.participation_agent import ColorCell -from democracy_sim.participation_model import (ParticipationModel, - distance_functions, - social_welfare_functions) -from math import factorial -import mesa - -# Parameters - -############# -# Elections # -############# -election_costs = 1 -max_reward = 50 -election_impact_on_mutation = 1.8 # 0.1-5.0 -mu = 0.05 # 0.001-0.5 -# Voting rules (see social_welfare_functions.py) -rule_idx = 1 -# Distance functions (see distance_functions.py) -distance_idx = 1 -#################### -# Model parameters # -#################### -num_agents = 800 -common_assets = 40000 -# Colors -num_colors = 3 -color_patches_steps = 3 -patch_power = 1.0 -color_heterogeneity = 0.3 -known_cells = 10 -# Voting Agents -num_personalities = 4 -# Grid -grid_rows = 100 # height -grid_cols = 80 # width -cell_size = 10 -canvas_height = grid_rows * cell_size -canvas_width = grid_cols * cell_size -draw_borders = True -# Voting Areas -num_areas = 16 -av_area_height = 25 -# area_height = grid_rows // int(sqrt(num_areas)) -av_area_width = 20 -# area_width = grid_cols // int(sqrt(num_areas)) -# num_areas = 4 -# av_area_height = 50 -# av_area_width = 40 -area_size_variance = 0.0 -######################## -# Statistics and Views # -######################## -show_area_stats = True - - -_COLORS = [ - "White", - "Red", - "Green", - "Blue", - "Yellow", - "Aqua", - "Fuchsia", - #"Lavender", - "Lime", - "Maroon", - #"Navy", - #"Olive", - "Orange", - #"Purple", - #"Silver", - #"Teal", - # "Pink", - # "Brown", - # "Gold", - # "Coral", - # "Crimson", - # "DarkBlue", - # "DarkRed", - # "DarkGreen", - # "DarkKhaki", - # "DarkMagenta", - # "DarkOliveGreen", - # "DarkOrange", - # "DarkTurquoise", - # "DarkViolet", - # "DeepPink", -] # 10 colors - - -def participation_draw(cell: ColorCell): - """ - This function is registered with the visualization server to be called - each tick to indicate how to draw the cell in its current color. - - Args: - cell: The cell in the simulation - - Returns: - The portrayal dictionary. - """ - if cell is None: - raise AssertionError - color = _COLORS[cell.color] - portrayal = {"Shape": "rect", "w": 1, "h": 1, "Filled": "true", "Layer": 0, - "x": cell.row, "y": cell.col, - "Color": color} - # TODO: maybe: draw the agent number in the opposing color - # If the cell is a border cell, change its appearance - if TYPE_CHECKING: # Type hint for IDEs - cell.model = cast(ParticipationModel, cell.model) - if cell.is_border_cell and cell.model.draw_borders: - portrayal["Shape"] = "circle" - portrayal["r"] = 0.9 # Adjust the radius to fit within the cell - if color == "White": - portrayal["Color"] = "LightGrey" - # Add position (x, y) to the hover-text - portrayal["Position"] = f"{cell.position}" - portrayal["Color - text"] = _COLORS[cell.color] - if cell.num_agents_in_cell > 0: - portrayal[f"text"] = str(cell.num_agents_in_cell) - portrayal["text_color"] = "Black" - for a in cell.areas: - unique_id = a.unique_id - if unique_id == -1: - unique_id = "global" - text = f"{a.num_agents} agents, color dist: {a.color_distribution}" - portrayal[f"Area {unique_id}"] = text - for voter in cell.agents: - text = f"personality: {voter.personality}, assets: {voter.assets}" - portrayal[f"Agent {voter.unique_id}"] = text - return portrayal - - -canvas_element = mesa.visualization.CanvasGrid( - participation_draw, grid_cols, grid_rows, canvas_width, canvas_height -) - - -wealth_chart = mesa.visualization.modules.ChartModule( - [{"Label": "Collective assets", "Color": "Black"}], - data_collector_name='datacollector' -) - - -color_distribution_chart = mesa.visualization.modules.ChartModule( - [{"Label": f"Color {i}", - "Color": "LightGrey" if _COLORS[i] == "White" else _COLORS[i]} - for i in range(len(_COLORS))], - data_collector_name='datacollector' - ) - -voter_turnout = mesa.visualization.ChartModule( - [{"Label": "Voter turnout globally (in percent)", "Color": "Black"}, - {"Label": "Gini Index (0-100)", "Color": "Red"}], - data_collector_name='datacollector') - - -model_params = { - "height": grid_rows, - "width": grid_cols, - "draw_borders": mesa.visualization.Checkbox( - name="Draw border cells", value=draw_borders - ), - "rule_idx": mesa.visualization.Slider( - name=f"Rule index {[r.__name__ for r in social_welfare_functions]}", - value=rule_idx, min_value=0, max_value=len(social_welfare_functions)-1, - ), - "distance_idx": mesa.visualization.Slider( - name=f"Dist-Function index {[f.__name__ for f in distance_functions]}", - value=distance_idx, min_value=0, max_value=len(distance_functions)-1, - ), - "election_costs": mesa.visualization.Slider( - name="Election costs", value=election_costs, min_value=0, max_value=100, - step=1, description="The costs for participating in an election" - ), - "max_reward": mesa.visualization.Slider( - name="Maximal reward", value=max_reward, min_value=0, - max_value=election_costs*100, - step=1, description="The costs for participating in an election" - ), - "mu": mesa.visualization.Slider( - name="Mutation rate", value=mu, min_value=0.001, max_value=0.5, - step=0.001, description="Probability of a color cell to mutate" - ), - "election_impact_on_mutation": mesa.visualization.Slider( - name="Election impact on mutation", value=election_impact_on_mutation, - min_value=0.1, max_value=5.0, step=0.1, - description="Factor determining how strong mutation accords to election" - ), - "num_agents": mesa.visualization.Slider( - name="# Agents", value=num_agents, min_value=10, max_value=99999, - step=10 - ), - "num_colors": mesa.visualization.Slider( - name="# Colors", value=num_colors, min_value=2, max_value=len(_COLORS), - step=1 - ), - "num_personalities": mesa.visualization.Slider( - name="# different personalities", value=num_personalities, - min_value=1, max_value=factorial(num_colors), step=1 - ), - "common_assets": mesa.visualization.Slider( - name="Initial common assets", value=common_assets, - min_value=num_agents, max_value=1000*num_agents, step=10 - ), - "known_cells": mesa.visualization.Slider( - name="# known fields", value=known_cells, - min_value=1, max_value=100, step=1 - ), - "color_patches_steps": mesa.visualization.Slider( - name="Patches size (# steps)", value=color_patches_steps, - min_value=0, max_value=9, step=1, - description="More steps lead to bigger color patches" - ), - "patch_power": mesa.visualization.Slider( - name="Patches power", value=patch_power, min_value=0.0, max_value=3.0, - step=0.2, description="Increases the power/radius of the color patches" - ), - "heterogeneity": mesa.visualization.Slider( - name="Global color distribution heterogeneity", - value=color_heterogeneity, min_value=0.0, max_value=0.9, step=0.1, - description="The higher the heterogeneity factor the greater the" + - "difference in how often some colors appear overall" - ), - "num_areas": mesa.visualization.Slider( - name=f"# Areas within the {grid_rows}x{grid_cols} world", step=1, - value=num_areas, min_value=4, max_value=min(grid_cols, grid_rows)//2 - ), - "av_area_height": mesa.visualization.Slider( - name="Av. area height", value=av_area_height, - min_value=2, max_value=grid_rows//2, - step=1, description="Select the average height of an area" - ), - "av_area_width": mesa.visualization.Slider( - name="Av. area width", value=av_area_width, - min_value=2, max_value=grid_cols//2, - step=1, description="Select the average width of an area" - ), - "area_size_variance": mesa.visualization.Slider( - name="Area size variance", value=area_size_variance, - # TODO there is a division by zero error for value=1.0 - check this - min_value=0.0, max_value=0.99, step=0.1, - description="Select the variance of the area sizes" - ), - "show_area_stats": mesa.visualization.Checkbox( - name="Show all statistics", value=show_area_stats - ), -} diff --git a/democracy_sim/run.py b/democracy_sim/run.py deleted file mode 100644 index 2e6f0fa..0000000 --- a/democracy_sim/run.py +++ /dev/null @@ -1,43 +0,0 @@ -from mesa.visualization.ModularVisualization import ModularServer -from democracy_sim.participation_model import ParticipationModel -from democracy_sim.model_setup import (model_params as params, canvas_element, - voter_turnout, wealth_chart, - color_distribution_chart) -from democracy_sim.visualisation_elements import * - - -class CustomModularServer(ModularServer): - """ This is to prevent double initialization of the model. - For some reason, the Server resets the model once on initialization - and again on server launch. - """ - def __init__(self, model_cls, visualization_elements, - name="Mesa Model", model_params=None, port=None): - self.initialized = False - super().__init__(model_cls, visualization_elements, name, model_params, - port) - - def reset_model(self): - if not self.initialized: - self.initialized = True - return # This ensures that the first reset-call is ignored - super().reset_model() - - -personality_distribution = PersonalityDistribution() -area_stats = AreaStats() -vto_areas = VoterTurnoutElement() -area_personality_dists = AreaPersonalityDists() - -server = CustomModularServer( - model_cls=ParticipationModel, - visualization_elements=[canvas_element, color_distribution_chart, - wealth_chart, voter_turnout, vto_areas, - personality_distribution, area_stats, - area_personality_dists], - name="DemocracySim", - model_params=params, -) - -if __name__ == "__main__": - server.launch(open_browser=True) diff --git a/docs/technical/api/Area.md b/docs/technical/api/Area.md index e43d86c..8048cbb 100644 --- a/docs/technical/api/Area.md +++ b/docs/technical/api/Area.md @@ -1,7 +1,7 @@ # Class `Area` -::: democracy_sim.participation_model.Area +::: src.agents.area.Area ## Private Method -::: democracy_sim.participation_model.Area._conduct_election +::: src.agents.area.Area._conduct_election diff --git a/docs/technical/api/ColorCell.md b/docs/technical/api/ColorCell.md index 7152413..837c704 100644 --- a/docs/technical/api/ColorCell.md +++ b/docs/technical/api/ColorCell.md @@ -1,3 +1,3 @@ # Class `ColorCell` -::: democracy_sim.participation_agent.ColorCell \ No newline at end of file +::: src.agents.color_cell.ColorCell \ No newline at end of file diff --git a/docs/technical/api/Model.md b/docs/technical/api/Model.md index c8cb990..737af8e 100644 --- a/docs/technical/api/Model.md +++ b/docs/technical/api/Model.md @@ -1,3 +1,3 @@ # Class `ParticipationModel` -::: democracy_sim.participation_model.ParticipationModel +::: src.models.participation_model.ParticipationModel diff --git a/docs/technical/api/Utility_functions.md b/docs/technical/api/Utility_functions.md deleted file mode 100644 index 0f333ad..0000000 --- a/docs/technical/api/Utility_functions.md +++ /dev/null @@ -1,3 +0,0 @@ -# Utility functions - -::: democracy_sim.participation_agent.combine_and_normalize \ No newline at end of file diff --git a/docs/technical/api/VoteAgent.md b/docs/technical/api/VoteAgent.md index c4c8375..4ab77e4 100644 --- a/docs/technical/api/VoteAgent.md +++ b/docs/technical/api/VoteAgent.md @@ -1,3 +1,3 @@ # Class `VoteAgent` -::: democracy_sim.participation_agent.VoteAgent +::: src.agents.vote_agent.VoteAgent diff --git a/docs/technical/api/inherited.md b/docs/technical/api/inherited.md index 007aeff..1417cf5 100644 --- a/docs/technical/api/inherited.md +++ b/docs/technical/api/inherited.md @@ -2,7 +2,6 @@ :::mesa.Model ---- --- ## Mesa Base Agent Class diff --git a/docs/technical/api/utility_functions.md b/docs/technical/api/utility_functions.md new file mode 100644 index 0000000..210779d --- /dev/null +++ b/docs/technical/api/utility_functions.md @@ -0,0 +1,25 @@ +# Utility functions + +## Vector Distances +::: src.utils.distance_functions + options: + show_root_heading: false + heading_level: 3 + show_category_heading: false + group_by_category: false + +## Simulation Indicators +::: src.utils.metrics + options: + show_root_heading: false + heading_level: 3 + show_category_heading: false + group_by_category: false + +## Social Choice +::: src.utils.social_welfare_functions + options: + show_root_heading: false + heading_level: 3 + show_category_heading: false + group_by_category: false diff --git a/docs/technical/approval_voting.md b/docs/technical/approval_voting.md deleted file mode 100644 index 9b5d0ef..0000000 --- a/docs/technical/approval_voting.md +++ /dev/null @@ -1,56 +0,0 @@ -# Problem of threshold in approval voting - -If we choose an architecture in which voters always provide a sum-normalized preference vector -for all voting rules, then approval voting has to have a threshold value to determine which options are approved. -This may take autonomy away from the voters, but it ensures that every voting rule is based on the same conditions -increasing comparability. It may also help to add more rules later on. - -### Idea - -Setting a fixed threshold of $ \frac{1}{m} $ for approval voting where m is the number of options. - -### Definitions and Setup - -- **Sum-normalized vector**: A preference vector $ \mathbf{p} = (p_1, p_2, \ldots, p_m) $ where each entry $ p_i $ represents the preference score for option $ i $, with the constraint $ \sum_{i=1}^m p_i = 1 $. -- **Threshold**: A fixed threshold of $ \frac{1}{m} $ is used to determine approval. If $ p_i \geq \frac{1}{m} $, the option $ i $ is considered "approved." - -### Average Number of Approved Values - -To find the average number of values approved, let's consider how many entries $ p_i $ would meet the threshold $ p_i \geq \frac{1}{m} $. - -1. **Expectation Calculation**: - - The expected number of approvals can be found by looking at the expected value of each $ p_i $ being greater than or equal to $ \frac{1}{m} $. - - For a sum-normalized vector, the average value of any $ p_i $ is $ \frac{1}{m} $. This is because the sum of all entries equals 1, and there are $ m $ entries. - -2. **Probability of Approval**: - - If the vector entries are randomly distributed, the probability of any given $ p_i $ being above the threshold is approximately 50%. This stems from the fact that the mean is $ \frac{1}{m} $, and assuming a uniform or symmetric distribution around this mean, half the entries would be above, and half below, in expectation. - -3. **Expected Number of Approvals**: - - Since each entry has a 50% chance of being above $ \frac{1}{m} $ in a uniform random distribution, the expected number of approved values is $ \frac{m}{2} $. - -Therefore, **on average, $ \frac{m}{2} $ values will be approved**. - -### Range of the Number of Approved Values - -The number of approved values can vary depending on how the preference scores are distributed. Here's the possible range: - -1. **Minimum Approved Values**: - - If all entries are below $ \frac{1}{m} $, then none would be approved. However, given the constraint that the vector sums to 1, at least one entry must be $ \frac{1}{m} $ or higher. Hence, the minimum number of approved values is **1**. - -2. **Maximum Approved Values**: - - The maximum occurs when as many values as possible are at least $ \frac{1}{m} $. In the extreme case, you could have all $ m $ entries equal $ \frac{1}{m} $ exactly, making them all approved. Thus, the maximum number of approved values is **m**. - -### Conclusion - -- **Average number of approved values**: $ \frac{m}{2} $. -- **Range of approved values**: From 1 (minimum) to $ m $ (maximum). - -Hence, in theory, voters can still approve between 1 and $ m $ options, -giving them the whole range of flexibility that approval voting offers. - -### Possibility for improvement - -We should consider implementing rule-specific voting into the agent's decision-making process -instead of leaving all rule-specifics to the aggregation process. -This would allow for a more realistic comparison of the rules. -For some rules, it would also give opportunities to significantly speed up the simulation process. \ No newline at end of file diff --git a/docs/technical/preference_relations.md b/docs/technical/preference_relations.md deleted file mode 100644 index 9bb916e..0000000 --- a/docs/technical/preference_relations.md +++ /dev/null @@ -1,23 +0,0 @@ -# How preference relations are defined and represented in the system - -## Introduction - -... - -## Definition - -A preference relation $\tau\in\mathbb{R}_{\geq 0}^m$ is a numpy vector of length $m$, -where $m$ is the number of options and each element $\tau[i]$ represents the normalized preference for option $i$, -with $\sum_{\tau}=1$. - -### Why using sum normalization? - -In computational social choice, **sum normalization** is more common than magnitude normalization. -This is because sum normalization aligns well with the interpretation of preference vectors as distributions -or weighted votes, which are prevalent in social choice scenarios. - -### Why using non-negative values? - -The preference values $\tau[i]$ are non-negative because they represent the strength of preference for each option. -Equvalently, they can be interpreted as the probability of selecting each option -or the (inverted or negative) distance of an option to the agents' ideal solution. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 6523426..3387017 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,7 +26,7 @@ nav: - Grid Cell: technical/api/ColorCell.md - Voting Agent: technical/api/VoteAgent.md - Inherited Classes: technical/api/inherited.md - - Utility Functions: technical/api/Utility_functions.md + - Utility Functions: technical/api/utility_functions.md #- User Guide: technical/user_guide.md #1. Provide step-by-step guides for common project usage. #- Examples: technical/examples.md #1. Show key use cases via practical code examples or interactive demos. #- Developer Docs: technical/dev_docs.md #Offer guidelines for contributing or extending the project (e.g., folder structure, conventions, CI/CD pipelines). @@ -86,6 +86,10 @@ plugins: - search - mkdocstrings: default_handler: python + handlers: + python: + options: + show_private_members: true # Extensions markdown_extensions: diff --git a/pyproject.toml b/pyproject.toml index e334c00..ad4c30e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,46 +1,76 @@ [tool.poetry] -name = "participation" -description = "Agent-based modeling (ABM) in Python" -requires-python = ">=3.10" +name = "DemocracySim" +version = "0.1.0" +description = "Simulated Multi-Agent Democracy for thesis experiments" +authors = ["Paul Kühnel"] +readme = "README.md" +license = "MIT" keywords = [ - "ABM", - "simulation", - "multi-agent", + "ABM", + "multi-agent simulation", + "Mesa", + "collective intelligence", + "computational social choice", + "simulated democracy" ] -readme = "README.md" +homepage = "https://github.com/jurikane/participation" +repository = "https://github.com/jurikane/participation" +python = "^3.11" [tool.poetry.dependencies] -python = "^3.10" -Mesa = "^2.3.0" -numpy = "^1.26.4" -solara = "^1.32.1" -matplotlib = "^3.9.0" -ipyvuetify = "^1.9.4" -seaborn = "^0.13.2" -click = "^8.1.7" -networkx = "^3.3" -pandas = "^2.2.2" -pytest = "^8.2.0" -toml = "^0.10.2" -Flask = "^3.0.3" -altair = "^5.3.0" -streamlit = "^1.34.0" +python = "^3.11" +mesa = "2.3.0" +mesa-replay = { git = "https://github.com/Logende/mesa-replay.git", rev = "main" } +numpy = "1.26.4" +pandas = "2.2.2" +matplotlib = "3.9.0" +seaborn = "0.13.2" +solara = "1.44.1" +ipyvuetify = "1.9.4" +click = "8.1.7" +networkx = "3.3" +flask = "3.0.3" +altair = "5.3.0" +streamlit = "1.37.1" +pydantic = "2.11.9" +pydantic-core = "2.33.2" +annotated-types = ">=0.6.0" +typing-inspection = ">=0.4.0" +PyYAML = "6.0.3" +toml = "0.10.2" +tornado = "6.4" +pytest = "8.2.0" +pytest-cov = "5.0.0" +iniconfig = "*" +pluggy = ">=1.5,<2.0" +black = "24.3.0" +mypy = "1.5.1" +toolz = "1.0.0" +blinker = ">=1.0.0,<2" +gitpython = "!=3.1.19,<4,>=3.0.7" +protobuf = ">=3.20,<6" +pyarrow = ">=7.0" +pydeck = ">=0.8.0b4,<1" +tenacity = ">=8.1.0,<9" + +[tool.poetry.dev-dependencies] +pytest = "8.2.0" +pytest-cov = "5.0.0" +black = "24.3.0" +mypy = "1.5.1" +mkdocs = "1.6.0" +mkdocs-material = "9.5.25" +mkdocs-autorefs = "1.3.0" +mkdocs-get-deps = "0.2.0" +mkdocs-git-revision-date-localized-plugin = "0.9.3" +mkdocs-material-extensions = "1.3.1" +mkdocs-static-i18n = "1.2.3" +mkdocstrings = "0.27.0" +mkdocstrings-python = "1.13.0" + +[tool.poetry.include] +"DemocracySim/configs/*.yaml" = "DemocracySim/configs" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" - -[project.optional-dependencies] -dev = [ - "pytest >= 4.6", - "pytest-cov", - "pytest-mock", -] -docs = [ - "mkdocs", - "mkdocs-material", -] - -[project.urls] -homepage = "https://github.com/jurikane/participation" -repository = "https://github.com/jurikane/participation" diff --git a/requirements.txt b/requirements.txt index cdd98e5..f7d1dc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,42 @@ -Mesa~=2.3.0 -numpy~=1.26.4 -solara~=1.35.1 -matplotlib~=3.9.0 -ipyvuetify~=1.9.4 -seaborn~=0.13.2 -click~=8.1.7 -networkx~=3.3 -pandas~=2.2.2 -pytest~=8.2.0 -pytest-cov~=5.0.0 -toml~=0.10.2 -Flask~=3.0.3 -altair~=5.3.0 -streamlit~=1.37.0 -mkdocs-git-revision-date-localized-plugin~=0.9.0 -mkdocs-static-i18n -mkdocs-static-i18n[material] -mkdocstrings -mkdocstrings[python] -git+https://github.com/Logende/mesa-replay@main#egg=Mesa-Replay \ No newline at end of file +mesa==2.3.0 +mesa-replay @ git+https://github.com/Logende/mesa-replay.git@main +numpy==1.26.4 +pandas==2.2.2 +matplotlib==3.9.0 +seaborn==0.13.2 +solara==1.44.1 +ipyvuetify==1.9.4 +click==8.1.7 +networkx==3.3 +flask==3.0.3 +altair==5.3.0 +streamlit==1.37.1 +pydantic==2.11.9 +pydantic-core==2.33.2 +annotated-types>=0.6.0 +typing-inspection>=0.4.0 +PyYAML==6.0.3 +toml==0.10.2 +tornado==6.4 +pytest==8.2.0 +pytest-cov==5.0.0 +iniconfig +pluggy>=1.5,<2.0 +black==24.3.0 +mypy==1.5.1 +mkdocs==1.6.0 +mkdocs-autorefs==1.3.0 +mkdocs-get-deps==0.2.0 +mkdocs-git-revision-date-localized-plugin==0.9.3 +mkdocs-material==9.5.25 +mkdocs-material-extensions==1.3.1 +mkdocs-static-i18n==1.2.3 +mkdocstrings==0.27.0 +mkdocstrings-python==1.13.0 +toolz==1.0.0 +blinker<2,>=1.0.0 +gitpython!=3.1.19,<4,>=3.0.7 +protobuf<6,>=3.20 +pyarrow>=7.0 +pydeck<1,>=0.8.0b4 +tenacity<9,>=8.1.0 \ No newline at end of file diff --git a/democracy_sim/__init__.py b/scripts/__init__.py similarity index 100% rename from democracy_sim/__init__.py rename to scripts/__init__.py diff --git a/scripts/run.py b/scripts/run.py new file mode 100644 index 0000000..f848fb2 --- /dev/null +++ b/scripts/run.py @@ -0,0 +1,27 @@ +""" +Script to run the DemocracySim model server. +Configure using a config file (YAML or TOML) inside the configs folder. +Use --config to specify a config file (YAML or TOML). +Example: +python -m scripts.run -c config.yaml --no-browser +""" +import argparse +from mesa.visualization.ModularVisualization import ModularServer +from src.config.loader import load_config +from src.model_setup import make_server + +def main(): + parser = argparse.ArgumentParser(description="Run DemocracySim") + parser.add_argument("--config", "-c", type=str, default=None, + help="Path to YAML/TOML config") + parser.add_argument("--no-browser", + action="store_true", + help="Do not open browser on launch") + args = parser.parse_args() + + cfg = load_config(args.config) + server: ModularServer = make_server(cfg) + server.launch(open_browser=not args.no_browser) + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..7fa6c73 --- /dev/null +++ b/src/agents/__init__.py @@ -0,0 +1,7 @@ +# python +# src/agents/__init__.py +from .area import Area +from .vote_agent import VoteAgent +from .color_cell import ColorCell + +__all__ = ["Area", "VoteAgent", "ColorCell"] diff --git a/src/agents/area.py b/src/agents/area.py new file mode 100644 index 0000000..4ae5b1c --- /dev/null +++ b/src/agents/area.py @@ -0,0 +1,354 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, cast, List +import numpy as np +from mesa import Agent +if TYPE_CHECKING: # Type hint for IDEs + from src.models.participation_model import ParticipationModel + from src.agents.color_cell import ColorCell + from src.agents.vote_agent import VoteAgent + + +class Area(Agent): + """ + While technically an agent, this class contains major parts of the simulation logic. + An area containing agents and cells, and is conducting the elections. + """ + def __init__(self, unique_id, model: ParticipationModel, + height, width, size_variance): + """ + Create a new area. + + Attributes: + unique_id (int): The unique identifier of the area. + model (ParticipationModel): The simulation model of which the area is part of. + height (int): The average height of the area (see size_variance). + width (int): The average width of the area (see size_variance). + size_variance (float): A variance factor applied to height and width. + """ + super().__init__(unique_id=unique_id, model=model) + self._set_dimensions(width, height, size_variance) + self.agents: List["VoteAgent"] = [] + self._personality_distribution = None + self.cells: List["ColorCell"] = [] + self._idx_field = None # An indexing position of the area in the grid + self._color_distribution = np.zeros(model.num_colors) # Initialize to 0 + self._voted_ordering = None + self._voter_turnout = 0 # In percent + self._dist_to_reality = None # Elected vs. actual color distribution + + def __str__(self): + return (f"Area(id={self.unique_id}, size={self._height}x{self._width}, " + f"at idx_field={self._idx_field}, " + f"num_agents={self.num_agents}, num_cells={self.num_cells}, " + f"color_distribution={self.color_distribution})") + + def _set_dimensions(self, width, height, size_var): + """ + Sets the area's dimensions based on the provided width, height, and variance factor. + + This function adjusts the width and height by a random factor drawn from + the range [1 - size_var, 1 + size_var]. If size_var is zero, no variance + is applied. + + Args: + width (int): The average width of the area. + height (int): The average height of the area. + size_var (float): A variance factor applied to width and height. + Must be in [0, 1]. + + Raises: + ValueError: If size_var is not between 0 and 1. + """ + if size_var == 0: + self._width = width + self._height = height + self.width_off, self.height_off = 0, 0 + elif size_var > 1 or size_var < 0: + raise ValueError("Size variance must be between 0 and 1") + else: # Apply variance + w_var_factor = self.random.uniform(1 - size_var, 1 + size_var) + h_var_factor = self.random.uniform(1 - size_var, 1 + size_var) + self._width = int(width * w_var_factor) + self.width_off = abs(width - self._width) + self._height = int(height * h_var_factor) + self.height_off = abs(height - self._height) + + @property + def num_agents(self): + return len(self.agents) + + @property + def num_cells(self): + return self._width * self._height + + @property + def personality_distribution(self): + return self._personality_distribution + + @property + def color_distribution(self): + return self._color_distribution + + @property + def voted_ordering(self): + return self._voted_ordering + + @property + def voter_turnout(self): + return self._voter_turnout + + @property + def dist_to_reality(self): + return self._dist_to_reality + + @property + def idx_field(self): + return self._idx_field + + @idx_field.setter + def idx_field(self, pos: tuple): + """ + Sets the indexing field (cell coordinate in the grid) of the area. + + This method sets the areas indexing-field (top-left cell coordinate) + which determines which cells and agents on the grid belong to the area. + The cells and agents are added to the area's lists of cells and agents. + + Args: + pos: (x, y) representing the areas top-left coordinates. + """ + # TODO: Check - isn't it better to make sure agents are added to the area when they are created? + # TODO -- There is something wrong here!!! (Agents are not added to the areas) + if TYPE_CHECKING: # Type hint for IDEs + self.model = cast(ParticipationModel, self.model) + try: + x_val, y_val = pos + except ValueError: + raise ValueError("The idx_field must be a tuple") + # Check if the values are within the grid + if x_val < 0 or x_val >= self.model.width: + raise ValueError(f"The x={x_val} value must be within the grid") + if y_val < 0 or y_val >= self.model.height: + raise ValueError(f"The y={y_val} value must be within the grid") + x_off = self.width_off // 2 + y_off = self.height_off // 2 + # Adjusting indices with offset and ensuring they wrap around the grid + adjusted_x = (x_val + x_off) % self.model.width + adjusted_y = (y_val + y_off) % self.model.height + # Assign the cells to the area + for x_area in range(self._width): + for y_area in range(self._height): + x = (adjusted_x + x_area) % self.model.width + y = (adjusted_y + y_area) % self.model.height + contents = self.model.grid.get_cell_list_contents([(x, y)]) + if not contents: + raise RuntimeError( + f"Grid cell ({x},{y}) is empty – expected a ColorCell.") + cell = contents[0] + if TYPE_CHECKING: + cell = cast(ColorCell, cell) + self.add_cell(cell) # Add the cell to the area + # Add all voting agents to the area + for agent in cell.agents: + self.add_agent(agent) + cell.add_area(self) # Add the area to the color-cell + # Mark as a border cell if true, but not for the global area + if self.unique_id != -1 and (x_area == 0 or y_area == 0 + or x_area == self._width - 1 + or y_area == self._height - 1): + cell.is_border_cell = True + self._idx_field = (adjusted_x, adjusted_y) + self._update_color_distribution() + self._update_personality_distribution() + + def _update_personality_distribution(self) -> None: + """ + This method calculates the areas current distribution of personalities. + """ + personalities = list(self.model.personalities) + p_counts = {str(i): 0 for i in personalities} + # Count the occurrence of each personality + for agent in self.agents: + p_counts[str(agent.personality)] += 1 + # Normalize the counts + if self.num_agents == 0: + self._personality_distribution = [0 for _ in personalities] + else: + self._personality_distribution = [p_counts[str(p)] / self.num_agents + for p in personalities] + + def add_agent(self, agent: VoteAgent) -> None: + """ + Appends an agent to the areas agents list. + + Args: + agent (VoteAgent): The agent to be added to the area. + """ + self.agents.append(agent) + + def add_cell(self, cell: ColorCell) -> None: + """ + Appends a cell to the areas cells list. + + Args: + cell (ColorCell): The agent to be added to the area. + """ + self.cells.append(cell) + + + def _conduct_election(self) -> int: + """ + Simulates the election within the area and manages rewards. + + The election process asks agents to participate, collects votes, + aggregates preferences using the model's voting rule, + and saves the elected option as the latest winning option. + Agents incur costs for participation + and may receive rewards based on the outcome. + + Returns: + int: The voter turnout in percent. Returns 0 if no agent participates. + """ + # Ask agents for participation and their votes + preference_profile = self._tally_votes() + # Check for the case that no agent participated + if preference_profile.ndim != 2 or preference_profile.shape[0] == 0: + # TODO: What to do in this case? Cease the simulation? + # Set to previous outcome but dont distribute rewards + print("Area", self.unique_id, "no one participated in the election") + # If no previous outcome, use the real distribution ordering + real_color_ord = np.argsort(self.color_distribution)[::-1] + if self._voted_ordering is None: + self._voted_ordering = real_color_ord + # Update dist_to_reality for monitoring but no rewards + self._dist_to_reality = self.model.distance_func( + real_color_ord, self._voted_ordering, + self.model.color_search_pairs + ) + return 0 + # Aggregate the preferences ⇒ returns an option ordering (indices into options) + aggregated = self.model.voting_rule(preference_profile) + # Save the "elected" ordering in self._voted_ordering + winning_option = aggregated[0] + self._voted_ordering = self.model.options[winning_option] + # Calculate and distribute rewards + self._distribute_rewards() + # TODO check whether the current color dist and the mutation of the + # colors is calculated and applied correctly and does not interfere + # in any way with the election process + # Statistics + n = preference_profile.shape[0] # Number agents participated + return int((n / self.num_agents) * 100) # Voter turnout in percent + + def _tally_votes(self): + """ + Gathers votes from agents who choose to (and can afford to) participate. + + Each participating agent contributes a vector of dissatisfaction values with + respect to the available options. These values are combined into a NumPy array. + + Returns: + np.ndarray: A 2D array representing the preference profiles of all + participating agents. Each row corresponds to an agent's vote. + """ + preference_profile = [] + for agent in self.agents: + el_costs = self.model.election_costs + # Give agents their (new) known fields + agent.update_known_cells(area=self) + if (agent.assets >= el_costs + and agent.ask_for_participation(area=self)): + agent.num_elections_participated += 1 + # Collect the participation fee + agent.assets = agent.assets - el_costs + # Ask the agent for her preference + preference_profile.append(agent.vote(area=self)) + # agent.vote returns an array containing dissatisfaction values + # between 0 and 1 for each option, interpretable as rank values. + return np.array(preference_profile) + + def _distribute_rewards(self) -> None: + """ + Calculates and distributes rewards (or penalties) to agents based on outcomes. + + The function measures the difference between the actual color distribution + and the elected outcome using a distance metric. It then increments or reduces + agent assets accordingly, ensuring assets do not fall below zero. + """ + model = self.model + # Calculate the distance to the real distribution using distance_func + real_color_ord = np.argsort(self.color_distribution)[::-1] # Descending + dist_func = model.distance_func + self._dist_to_reality = dist_func(real_color_ord, self.voted_ordering, + model.color_search_pairs) + # Calculate the rpa - rewards per agent (can be negative) + rpa = (0.5 - self.dist_to_reality) * model.max_reward # TODO: change this (?) + # Distribute the two types of rewards + color_search_pairs = model.color_search_pairs + for a in self.agents: + # Personality-based reward factor + p = dist_func(a.personality, real_color_ord, color_search_pairs) + # + common reward (reward_pa) for all agents + pers_reward = (0.5 - p) * model.max_reward # Personality-based reward + a.assets = max(0, int(a.assets + pers_reward + rpa)) + + def _update_color_distribution(self) -> None: + """ + Recalculates the area's color distribution and updates the _color_distribution attribute. + + This method counts how many cells of each color belong to the area, normalizes + the counts by the total number of cells, and stores the result internally. + """ + color_count = {} + for cell in self.cells: + color = cell.color + color_count[color] = color_count.get(color, 0) + 1 + for color in range(self.model.num_colors): + dist_val = color_count.get(color, 0) / self.num_cells # Float + self._color_distribution[color] = dist_val + + def _filter_cells(self, cell_list): + """ + This method is used to filter a given list of cells to return only + those which are within the area. + + Args: + cell_list: A list of ColorCell cells to be filtered. + + Returns: + A list of ColorCell cells that are within the area. + """ + cell_set = set(self.cells) + return [c for c in cell_list if c in cell_set] + + def step(self) -> None: + """ + Run one step of the simulation. + + Conduct an election in the area, + mutate the cells' colors according to the election outcome + and update the color distribution of the area. + """ + self._voter_turnout = self._conduct_election() # The main election logic! + if self.voter_turnout == 0: + return # TODO: What to do if no agent participated..? + + # Mutate colors in cells + # Take some number of cells to mutate (i.e., 5 %) + n_to_mutate = int(self.model.mu * self.num_cells) + # TODO/Idea: What if the voter_turnout determines the mutation rate? + # randomly select x cells + cells_to_mutate = self.random.sample(self.cells, n_to_mutate) + # Use voted ordering to pick colors in descending order + # To pre-select colors for all cells to mutate + # TODO: Think about this: should we take local color-structure + # into account - like in color patches - to avoid colors mutating into + # very random structures? # Middendorf + colors = self.model.np_random.choice(self.voted_ordering, + size=n_to_mutate, + p=self.model.color_probs) + # Assign the newly selected colors to the cells + for cell, color in zip(cells_to_mutate, colors): + cell.color = color + # Important: Update the color distribution (because colors changed) + self._update_color_distribution() diff --git a/src/agents/color_cell.py b/src/agents/color_cell.py new file mode 100644 index 0000000..4032906 --- /dev/null +++ b/src/agents/color_cell.py @@ -0,0 +1,89 @@ +from mesa import Agent + + +class ColorCell(Agent): + """ + Represents a single cell (a field in the grid) with a specific color. + + Attributes: + color (int): The color of the cell. + """ + + def __init__(self, unique_id, model, pos: tuple, initial_color: int): + """ + Initializes a ColorCell, at the given row, col position. + + Args: + unique_id (int): The unique identifier of the cell. + model (mesa.Model): The mesa model of which the cell is part of. + pos (Tuple[int, int]): The position of the cell in the grid. + initial_color (int): The initial color of the cell. + """ + super().__init__(unique_id, model) + # The "pos" variable in mesa is special, so I avoid it here + self._row = pos[0] + self._col = pos[1] + self.color = initial_color # The cell's current color (int) + self._next_color = None + self.agents = [] + self.areas = [] + self.is_border_cell = False + + def __str__(self): + return (f"Cell ({self.unique_id}, pos={self.position}, " + f"color={self.color}, num_agents={self.num_agents_in_cell})") + + @property + def col(self): + """The col location of this cell.""" + return self._col + + @property + def row(self): + """The row location of this cell.""" + return self._row + + @property + def position(self): # The variable pos is special in mesa! + """The location of this cell.""" + return self._row, self._col + + @property + def num_agents_in_cell(self): + """The number of agents in this cell.""" + return len(self.agents) + + def add_agent(self, agent): + self.agents.append(agent) + + def remove_agent(self, agent): + self.agents.remove(agent) + + def add_area(self, area): + self.areas.append(area) + + def color_step(self): + """ + Determines the cells' color for the next step. + TODO + """ + # _neighbor_iter = self.model.grid.iter_neighbors( + # (self._row, self._col), True) + # neighbors_opinion = Counter(n.get_state() for n in _neighbor_iter) + # # Following is a tuple (attribute, occurrences) + # polled_opinions = neighbors_opinion.most_common() + # tied_opinions = [] + # for neighbor in polled_opinions: + # if neighbor[1] == polled_opinions[0][1]: + # tied_opinions.append(neighbor) + # + # self._next_color = self.random.choice(tied_opinions)[0] + pass + + def advance(self): + """ + Set the state of the agent to the next state. + TODO + """ + # self._color = self._next_color + pass diff --git a/democracy_sim/participation_agent.py b/src/agents/vote_agent.py similarity index 64% rename from democracy_sim/participation_agent.py rename to src/agents/vote_agent.py index adae33c..2cfa77b 100644 --- a/democracy_sim/participation_agent.py +++ b/src/agents/vote_agent.py @@ -1,8 +1,11 @@ +from __future__ import annotations from typing import TYPE_CHECKING, cast, List, Optional import numpy as np from mesa import Agent if TYPE_CHECKING: # Type hint for IDEs - from democracy_sim.participation_model import ParticipationModel + from src.models.participation_model import ParticipationModel + from src.agents.color_cell import ColorCell + from src.agents.area import Area def combine_and_normalize(arr_1: np.array, arr_2: np.array, factor: float): @@ -12,12 +15,12 @@ def combine_and_normalize(arr_1: np.array, arr_2: np.array, factor: float): And the other is to be the personality vector of the agent. Args: - arr_1: The first array to be combined (real distribution). - arr_2: The second array to be combined (personality vector). - factor: The factor to weigh the two arrays. + arr_1 (np.array): Estimated real distribution. + arr_2 (np.array): Personality vector. + factor (float): Weight for arr_1. Returns: - result (np.array): The normalized weighted linear combination. + result (np.array): Normalized weighted linear combination. Example: TODO @@ -38,7 +41,8 @@ class VoteAgent(Agent): can decide to use them to participate in elections. """ - def __init__(self, unique_id, model, pos, personality, assets=1, add=True): + def __init__(self, unique_id, model: ParticipationModel, pos, + personality=None, personality_idx=None, assets=1, add=True): """ Create a new agent. Attributes: @@ -46,6 +50,7 @@ def __init__(self, unique_id, model, pos, personality, assets=1, add=True): model: The simulation model of which the agent is part of. pos: The position of the agent in the grid. personality: Represents the agent's preferences among colors. + personality_idx: Index of personality in model's personalities list. assets: The wealth/assets/motivation of the agent. """ super().__init__(unique_id=unique_id, model=model) @@ -58,6 +63,7 @@ def __init__(self, unique_id, model, pos, personality, assets=1, add=True): self._assets = assets self._num_elections_participated = 0 self.personality = personality + self.personality_idx = personality_idx self.cell = model.grid.get_cell_list_contents([(row, col)])[0] # ColorCell objects the agent knows (knowledge) self.known_cells: List[Optional[ColorCell]] = [None] * model.known_cells @@ -75,22 +81,22 @@ def __str__(self): f"personality={self.personality}, assets={self.assets})") @property - def position(self): + def position(self) -> tuple: """Return the location of the agent.""" return self._position @property - def row(self): + def row(self) -> int: """Return the row location of the agent.""" return self._position[0] @property - def col(self): + def col(self) -> int: """Return the col location of the agent.""" return self._position[1] @property - def assets(self): + def assets(self) -> int: """Return the assets of this agent.""" return self._assets @@ -103,19 +109,20 @@ def assets(self): del self._assets @property - def num_elections_participated(self): + def num_elections_participated(self) -> int: + """Return the number of elections this agent has participated in.""" return self._num_elections_participated @num_elections_participated.setter def num_elections_participated(self, value): self._num_elections_participated = value - def update_known_cells(self, area): + def update_known_cells(self, area: Area): """ This method is to update the list of known cells before casting a vote. Args: - area: The area that holds the pool of cells in question + area (Area): The area that holds the pool of cells in question """ n_cells = len(area.cells) k = len(self.known_cells) @@ -125,13 +132,12 @@ def update_known_cells(self, area): else area.cells ) - def ask_for_participation(self, area): + def ask_for_participation(self, area: Area) -> bool: """ - The agent decides - whether to participate in the upcoming election of a given area. + Decide whether to participate in the given area's election. Args: - area: The area in which the election takes place. + area (Area): The area in which the election takes place. Returns: True if the agent decides to participate, False otherwise @@ -139,19 +145,22 @@ def ask_for_participation(self, area): #print("Agent", self.unique_id, "decides whether to participate", # "in election of area", area.unique_id) # TODO Implement this (is to be decided upon a learned decision tree) - return np.random.choice([True, False]) + return bool(self.random.choice([True, False])) - def decide_altruism_factor(self, area): + def decide_altruism_factor(self, area: Area) -> float: """ Uses a trained decision tree to decide on the altruism factor. + + Returns: + float """ # TODO Implement this (is to be decided upon a learned decision tree) # This part is important - also for monitoring - save/plot a_factors - a_factor = np.random.uniform(0.0, 1.0) + a_factor = self.random.uniform(0.0, 1.0) #print(f"Agent {self.unique_id} has an altruism factor of: {a_factor}") return a_factor - def compute_assumed_opt_dist(self, area): + def compute_assumed_opt_dist(self, area: Area) -> np.array: """ Computes a color distribution that the agent assumes to be an optimal choice in any election (regardless of whether it exists as a real option @@ -161,8 +170,12 @@ def compute_assumed_opt_dist(self, area): area (Area): The area in which the election takes place. Returns: - ass_opt: The assumed optimal color distribution (normalized). + np.array: The assumed optimal color distribution (normalized). """ + # TODO PRIO 4 (this part is not used) => think about using personality + # as dist and personality_idx as is (pointer to ordering) and use either a + # s required | also think about making classes for orders and dists + # to not confuse them and have it set up correctly and well documented # Compute the "altruism_factor" via a decision tree a_factor = self.decide_altruism_factor(area) # TODO: Implement this # Compute the preference ranking vector as a mix between the agent's own @@ -171,7 +184,7 @@ def compute_assumed_opt_dist(self, area): ass_opt = combine_and_normalize(est_dist, self.personality, a_factor) return ass_opt - def vote(self, area): + def vote(self, area: Area): """ The agent votes in the election of a given area, i.e., she returns a preference ranking vector over all options. @@ -183,7 +196,7 @@ def vote(self, area): """ # TODO Implement this (is to be decided upon a learned decision tree) # Compute the color distribution that is assumed to be the best choice. - est_best_dist = self.compute_assumed_opt_dist(area) + est_best_dist = self.compute_assumed_opt_dist(area) # TODO !!! (Why is this not used ???) # Make sure that r= is normalized! # (r.min()=0.0 and r.max()=1.0 and all vals x are within [0.0, 1.0]!) ############## @@ -200,13 +213,16 @@ def vote(self, area): ranking /= ranking.sum() # Normalize the preference vector return ranking - def estimate_real_distribution(self, area): + def estimate_real_distribution(self, area: Area) -> tuple[np.array, float]: """ The agent estimates the real color distribution in the area based on her own knowledge (self.known_cells). Args: area (Area): The area the agent uses to estimate. + + Returns: + tuple[np.array, float]: (distribution, confidence) """ known_colors = np.array([cell.color for cell in self.known_cells]) # Get the unique color ids present and count their occurrence @@ -216,91 +232,3 @@ def estimate_real_distribution(self, area): self.est_real_dist[unique] = counts / known_colors.size self.confidence = len(self.known_cells) / area.num_cells return self.est_real_dist, self.confidence - - -class ColorCell(Agent): - """ - Represents a single cell (a field in the grid) with a specific color. - - Attributes: - color (int): The color of the cell. - """ - - def __init__(self, unique_id, model, pos: tuple, initial_color: int): - """ - Initializes a ColorCell, at the given row, col position. - - Args: - unique_id (int): The unique identifier of the cell. - model (mesa.Model): The mesa model of which the cell is part of. - pos (Tuple[int, int]): The position of the cell in the grid. - initial_color (int): The initial color of the cell. - """ - super().__init__(unique_id, model) - # The "pos" variable in mesa is special, so I avoid it here - self._row = pos[0] - self._col = pos[1] - self.color = initial_color # The cell's current color (int) - self._next_color = None - self.agents = [] - self.areas = [] - self.is_border_cell = False - - def __str__(self): - return (f"Cell ({self.unique_id}, pos={self.position}, " - f"color={self.color}, num_agents={self.num_agents_in_cell})") - - @property - def col(self): - """The col location of this cell.""" - return self._col - - @property - def row(self): - """The row location of this cell.""" - return self._row - - @property - def position(self): # The variable pos is special in mesa! - """The location of this cell.""" - return self._row, self._col - - @property - def num_agents_in_cell(self): - """The number of agents in this cell.""" - return len(self.agents) - - def add_agent(self, agent): - self.agents.append(agent) - - def remove_agent(self, agent): - self.agents.remove(agent) - - def add_area(self, area): - self.areas.append(area) - - def color_step(self): - """ - Determines the cells' color for the next step. - TODO - """ - # _neighbor_iter = self.model.grid.iter_neighbors( - # (self._row, self._col), True) - # neighbors_opinion = Counter(n.get_state() for n in _neighbor_iter) - # # Following is a tuple (attribute, occurrences) - # polled_opinions = neighbors_opinion.most_common() - # tied_opinions = [] - # for neighbor in polled_opinions: - # if neighbor[1] == polled_opinions[0][1]: - # tied_opinions.append(neighbor) - # - # self._next_color = self.random.choice(tied_opinions)[0] - pass - - def advance(self): - """ - Set the state of the agent to the next state. - TODO - """ - # self._color = self._next_color - pass diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config/loader.py b/src/config/loader.py new file mode 100644 index 0000000..9e8256a --- /dev/null +++ b/src/config/loader.py @@ -0,0 +1,53 @@ +from pathlib import Path +from src.config.schema import AppConfig +import yaml +import os + + +def check_schema(open_file): + """ + Load configuration from an open YAML file + and validate against AppConfig schema. + """ + raw = yaml.safe_load(open_file) + return AppConfig.model_validate(raw) + + +def load_config(config_file=None): + """ + Load configuration from a YAML file. + """ + if config_file is None: + config_file = os.environ.get("CONFIG_FILE", "default.yaml") + + cfg = Path(config_file) + + # Use absolute or direct if exists + if cfg.is_absolute() and cfg.exists(): + with cfg.open("r") as f: + return check_schema(f) + if cfg.exists(): + with cfg.open("r") as f: + return check_schema(f) + + # Try CWD (when invoked from project root) + cwd_path = Path.cwd() / cfg + if cwd_path.exists(): + with cwd_path.open("r") as f: + return check_schema(f) + + # Try project-root `configs/` + project_root = Path(__file__).resolve().parents[2] + root_cfg = project_root / "configs" / cfg.name + if root_cfg.exists(): + with root_cfg.open("r") as f: + return check_schema(f) + + # Legacy fallback: src/configs/ + legacy = project_root / "src" / "configs" / cfg.name + if legacy.exists(): + with legacy.open("r") as f: + return check_schema(f) + + tried = [str(p) for p in [cfg, cwd_path, root_cfg, legacy]] + raise FileNotFoundError(f"Config not found. Tried: {', '.join(tried)}") diff --git a/src/config/schema.py b/src/config/schema.py new file mode 100644 index 0000000..45d5fd9 --- /dev/null +++ b/src/config/schema.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel +from typing import Optional + +class ModelConfig(BaseModel): + """ + Configuration for the core simulation model. + """ + election_costs: float # Cost for participating in an election + max_reward: float # Maximum possible reward per election + election_impact_on_mutation: float # Impact of election on mutation rate + mu: float # Mutation rate + rule_idx: int # Index of the voting rule to use + distance_idx: int # Index of the distance function to use + num_agents: int # Number of agents in the simulation + common_assets: int # Initial collective assets + num_colors: int # Number of color options + color_patches_steps: int # Steps for color patch adjustment + patch_power: float # Power/radius of color patching + heterogeneity: float # Heterogeneity factor for color distribution + known_cells: int # Number of cells each agent knows + num_personalities: int # Number of unique agent personalities + height: int # Grid height + width: int # Grid width + num_areas: int # Number of areas (territories) + av_area_height: int # Average area height + av_area_width: int # Average area width + area_size_variance: float # Variance in area sizes + seed: Optional[int] = None # Random seed for reproducibility + +class VisualizationConfig(BaseModel): + """ + Configuration for visualization settings. + """ + cell_size: int = 10 # Size of each grid cell in pixels + draw_borders: bool # Whether to draw area borders + show_area_stats: Optional[bool] = True # Show area statistics overlay + +class SimulationConfig(BaseModel): + """ + Configuration for simulation runs and storage. + """ + runs: int # Number of simulation runs + num_steps: int # Number of steps per run + processes: int # Number of parallel processes + store_grid: bool # Whether to store grid state + grid_interval: int # Interval for storing grid state + +class AppConfig(BaseModel): + """ + Top-level application configuration. + """ + model: ModelConfig + visualization: VisualizationConfig + simulation: SimulationConfig diff --git a/src/model_setup.py b/src/model_setup.py new file mode 100644 index 0000000..957a96d --- /dev/null +++ b/src/model_setup.py @@ -0,0 +1,213 @@ +""" +Wires config -> ParticipationModel kwargs -> Mesa UI server. +""" +from src.config.schema import AppConfig, ModelConfig +from math import factorial +import mesa +from mesa.visualization.ModularVisualization import ModularServer +from src.models.participation_model import ( + ParticipationModel, + distance_functions, + social_welfare_functions, +) +from src.viz.factory import make_canvas, make_charts + +# The arguments accepted by ParticipationModel.__init__ +_ALLOWED_KW = { + "height", + "width", + "num_agents", + "num_colors", + "num_personalities", + "mu", + "election_impact_on_mutation", + "common_assets", + "known_cells", + "num_areas", + "av_area_height", + "av_area_width", + "area_size_variance", + "patch_power", + "color_patches_steps", + "heterogeneity", + "rule_idx", + "distance_idx", + "election_costs", + "max_reward", + "seed", +} + + +def build_model_kwargs(model_cfg: ModelConfig) -> dict: + """ + Create a kwargs dict for ParticipationModel from a config mapping + (used for headless or non-interactive runs). + """ + return {k: getattr(model_cfg, k) for k in _ALLOWED_KW if + hasattr(model_cfg, k)} + + +def build_model_params(model_cfg: AppConfig) -> dict: + """ + Create Mesa UI sliders/params so the web UI shows controls. + """ + height = model_cfg.height + width = model_cfg.width + num_colors = model_cfg.num_colors + num_agents = model_cfg.num_agents + + params = { + "height": height, + "width": width, + "rule_idx": mesa.visualization.Slider( + name=f"Rule index {[r.__name__ for r in social_welfare_functions]}", + value=model_cfg.rule_idx, + min_value=0, + max_value=len(social_welfare_functions) - 1, + step=1, + ), + "distance_idx": mesa.visualization.Slider( + name=f"Dist-Function index {[f.__name__ for f in distance_functions]}", + value=model_cfg.distance_idx, + min_value=0, + max_value=len(distance_functions) - 1, + step=1, + ), + "election_costs": mesa.visualization.Slider( + name="Election costs", + value=model_cfg.election_costs, + min_value=0, + max_value=100, + step=1, + ), + "max_reward": mesa.visualization.Slider( + name="Maximal reward", + value=model_cfg.max_reward, + min_value=0, + max_value=max(1, int(model_cfg.election_costs) * 100), + step=1, + ), + "mu": mesa.visualization.Slider( + name="Mutation rate", + value=model_cfg.mu, + min_value=0.001, + max_value=0.5, + step=0.001, + ), + "election_impact_on_mutation": mesa.visualization.Slider( + name="Election impact on mutation", + value=model_cfg.election_impact_on_mutation, + min_value=0.1, + max_value=5.0, + step=0.1, + ), + "num_agents": mesa.visualization.Slider( + name="# Agents", + value=num_agents, + min_value=10, + max_value=99999, + step=10, + ), + "num_colors": mesa.visualization.Slider( + name="# Colors", + value=num_colors, + min_value=2, + max_value=max(2, num_colors), + step=1, + ), + "num_personalities": mesa.visualization.Slider( + name="# different personalities", + value=model_cfg.num_personalities, + min_value=1, + max_value=max(1, factorial(num_colors)), + step=1, + ), + "common_assets": mesa.visualization.Slider( + name="Initial common assets", + value=model_cfg.common_assets, + min_value=num_agents, + max_value=1000 * num_agents, + step=10, + ), + "known_cells": mesa.visualization.Slider( + name="# known fields", + value=model_cfg.known_cells, + min_value=1, + max_value=100, + step=1, + ), + "color_patches_steps": mesa.visualization.Slider( + name="Patches size (# steps)", + value=model_cfg.color_patches_steps, + min_value=0, + max_value=9, + step=1, + ), + "patch_power": mesa.visualization.Slider( + name="Patches power", + value=model_cfg.patch_power, + min_value=0.0, + max_value=3.0, + step=0.2, + ), + "heterogeneity": mesa.visualization.Slider( + name="Global color distribution heterogeneity", + value=model_cfg.heterogeneity, + min_value=0.0, + max_value=0.9, + step=0.1, + ), + "num_areas": mesa.visualization.Slider( + name=f"# Areas within the {height}x{width} world", + value=model_cfg.num_areas, + min_value=1, + max_value=max(1, min(width, height) // 2), + step=1, + ), + "av_area_height": mesa.visualization.Slider( + name="Av. area height", + value=model_cfg.av_area_height, + min_value=2, + max_value=max(2, height // 2), + step=1, + ), + "av_area_width": mesa.visualization.Slider( + name="Av. area width", + value=model_cfg.av_area_width, + min_value=2, + max_value=max(2, width // 2), + step=1, + ), + "area_size_variance": mesa.visualization.Slider( + name="Area size variance", + value=model_cfg.area_size_variance, + min_value=0.0, + max_value=0.99, + step=0.1, + ), + } + if model_cfg.seed is not None: + params["seed"] = model_cfg.seed + return params + + +def make_model(cfg: ModelConfig) -> ParticipationModel: + """ + Instantiate the model using the loaded config (non-UI usage). + """ + kwargs = build_model_kwargs(cfg.model) + return ParticipationModel(**kwargs) + + +def make_server(cfg: AppConfig) -> ModularServer: + """ + Build the ModularServer with CanvasGrid, charts, and UI sliders. + """ + vis_cfg = cfg.visualization + elements = [make_canvas(cfg), *make_charts(cfg)] + title = getattr(vis_cfg, "title", "Participation Model") + + # Use interactive model parameters (sliders appear in the UI) + params = build_model_params(cfg.model) + + return ModularServer(ParticipationModel, elements, title, params) diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/democracy_sim/participation_model.py b/src/models/participation_model.py similarity index 51% rename from democracy_sim/participation_model.py rename to src/models/participation_model.py index 8555dc5..6c3ae95 100644 --- a/democracy_sim/participation_model.py +++ b/src/models/participation_model.py @@ -1,11 +1,14 @@ -from typing import TYPE_CHECKING, cast, List, Optional +from typing import TYPE_CHECKING, cast, List, Optional, Callable import mesa -from democracy_sim.participation_agent import VoteAgent, ColorCell -from democracy_sim.social_welfare_functions import majority_rule, approval_voting -from democracy_sim.distance_functions import spearman, kendall_tau -from itertools import permutations, product, combinations -from math import sqrt import numpy as np +from math import factorial +from src.agents import Area, VoteAgent, ColorCell +from src.utils.social_welfare_functions import majority_rule, approval_voting +from src.utils.distance_functions import spearman, kendall_tau +from itertools import permutations, product, combinations +from src.utils.metrics import (compute_gini_index, compute_collective_assets, + get_voter_turnout, get_grid_colors) + # Voting rules to be accessible by index social_welfare_functions = [majority_rule, approval_voting] @@ -13,434 +16,6 @@ distance_functions = [spearman, kendall_tau] -class Area(mesa.Agent): - def __init__(self, unique_id, model, height, width, size_variance): - """ - Create a new area. - - Attributes: - unique_id (int): The unique identifier of the area. - model (ParticipationModel): The simulation model of which the area is part of. - height (int): The average height of the area (see size_variance). - width (int): The average width of the area (see size_variance). - size_variance (float): A variance factor applied to height and width. - """ - if TYPE_CHECKING: # Type hint for IDEs - model = cast(ParticipationModel, model) - super().__init__(unique_id=unique_id, model=model) - self._set_dimensions(width, height, size_variance) - self.agents = [] - self._personality_distribution = None - self.cells = [] - self._idx_field = None # An indexing position of the area in the grid - self._color_distribution = np.zeros(model.num_colors) # Initialize to 0 - self._voted_ordering = None - self._voter_turnout = 0 # In percent - self._dist_to_reality = None # Elected vs. actual color distribution - - def __str__(self): - return (f"Area(id={self.unique_id}, size={self._height}x{self._width}, " - f"at idx_field={self._idx_field}, " - f"num_agents={self.num_agents}, num_cells={self.num_cells}, " - f"color_distribution={self.color_distribution})") - - def _set_dimensions(self, width, height, size_var): - """ - Sets the area's dimensions based on the provided width, height, and variance factor. - - This function adjusts the width and height by a random factor drawn from - the range [1 - size_var, 1 + size_var]. If size_var is zero, no variance - is applied. - - Args: - width (int): The average width of the area. - height (int): The average height of the area. - size_var (float): A variance factor applied to width and height. - Must be in [0, 1]. - - Raises: - ValueError: If size_var is not between 0 and 1. - """ - if size_var == 0: - self._width = width - self._height = height - self.width_off, self.height_off = 0, 0 - elif size_var > 1 or size_var < 0: - raise ValueError("Size variance must be between 0 and 1") - else: # Apply variance - w_var_factor = self.random.uniform(1 - size_var, 1 + size_var) - h_var_factor = self.random.uniform(1 - size_var, 1 + size_var) - self._width = int(width * w_var_factor) - self.width_off = abs(width - self._width) - self._height = int(height * h_var_factor) - self.height_off = abs(height - self._height) - - @property - def num_agents(self): - return len(self.agents) - - @property - def num_cells(self): - return self._width * self._height - - @property - def personality_distribution(self): - return self._personality_distribution - - @property - def color_distribution(self): - return self._color_distribution - - @property - def voted_ordering(self): - return self._voted_ordering - - @property - def voter_turnout(self): - return self._voter_turnout - - @property - def dist_to_reality(self): - return self._dist_to_reality - - @property - def idx_field(self): - return self._idx_field - - @idx_field.setter - def idx_field(self, pos: tuple): - """ - Sets the indexing field (cell coordinate in the grid) of the area. - - This method sets the areas indexing-field (top-left cell coordinate) - which determines which cells and agents on the grid belong to the area. - The cells and agents are added to the area's lists of cells and agents. - - Args: - pos: (x, y) representing the areas top-left coordinates. - """ - # TODO: Check - isn't it better to make sure agents are added to the area when they are created? - # TODO -- There is something wrong here!!! (Agents are not added to the areas) - if TYPE_CHECKING: # Type hint for IDEs - self.model = cast(ParticipationModel, self.model) - try: - x_val, y_val = pos - except ValueError: - raise ValueError("The idx_field must be a tuple") - # Check if the values are within the grid - if x_val < 0 or x_val >= self.model.width: - raise ValueError(f"The x={x_val} value must be within the grid") - if y_val < 0 or y_val >= self.model.height: - raise ValueError(f"The y={y_val} value must be within the grid") - x_off = self.width_off // 2 - y_off = self.height_off // 2 - # Adjusting indices with offset and ensuring they wrap around the grid - adjusted_x = (x_val + x_off) % self.model.width - adjusted_y = (y_val + y_off) % self.model.height - # Assign the cells to the area - for x_area in range(self._width): - for y_area in range(self._height): - x = (adjusted_x + x_area) % self.model.width - y = (adjusted_y + y_area) % self.model.height - cell = self.model.grid.get_cell_list_contents([(x, y)])[0] - if TYPE_CHECKING: - cell = cast(ColorCell, cell) - self.add_cell(cell) # Add the cell to the area - # Add all voting agents to the area - for agent in cell.agents: - self.add_agent(agent) - cell.add_area(self) # Add the area to the color-cell - # Mark as a border cell if true - if (x_area == 0 or y_area == 0 - or x_area == self._width - 1 - or y_area == self._height - 1): - cell.is_border_cell = True - self._idx_field = (adjusted_x, adjusted_y) - self._update_color_distribution() - self._update_personality_distribution() - - def _update_personality_distribution(self) -> None: - """ - This method calculates the areas current distribution of personalities. - """ - personalities = list(self.model.personalities) - p_counts = {str(i): 0 for i in personalities} - # Count the occurrence of each personality - for agent in self.agents: - p_counts[str(agent.personality)] += 1 - # Normalize the counts - self._personality_distribution = [p_counts[str(p)] / self.num_agents - for p in personalities] - - def add_agent(self, agent: VoteAgent) -> None: - """ - Appends an agent to the areas agents list. - - Args: - agent (VoteAgent): The agent to be added to the area. - """ - self.agents.append(agent) - - def add_cell(self, cell: ColorCell) -> None: - """ - Appends a cell to the areas cells list. - - Args: - cell (ColorCell): The agent to be added to the area. - """ - self.cells.append(cell) - - - def _conduct_election(self) -> int: - """ - Simulates the election within the area and manages rewards. - - The election process asks agents to participate, collects votes, - aggregates preferences using the model's voting rule, - and saves the elected option as the latest winning option. - Agents incur costs for participation - and may receive rewards based on the outcome. - - Returns: - int: The voter turnout in percent. Returns 0 if no agent participates. - """ - # Ask agents for participation and their votes - preference_profile = self._tally_votes() - # Check for the case that no agent participated - if preference_profile.ndim != 2: - print("Area", self.unique_id, "no one participated in the election") - return 0 # TODO: What to do in this case? Cease the simulation? - # Aggregate the preferences ⇒ returns an option ordering - aggregated = self.model.voting_rule(preference_profile) - # Save the "elected" ordering in self._voted_ordering - winning_option = aggregated[0] - self._voted_ordering = self.model.options[winning_option] - # Calculate and distribute rewards - self._distribute_rewards() - # TODO check whether the current color dist and the mutation of the - # colors is calculated and applied correctly and does not interfere - # in any way with the election process - # Statistics - n = preference_profile.shape[0] # Number agents participated - return int((n / self.num_agents) * 100) # Voter turnout in percent - - def _tally_votes(self): - """ - Gathers votes from agents who choose to (and can afford to) participate. - - Each participating agent contributes a vector of dissatisfaction values with - respect to the available options. These values are combined into a NumPy array. - - Returns: - np.ndarray: A 2D array representing the preference profiles of all - participating agents. Each row corresponds to an agent's vote. - """ - preference_profile = [] - for agent in self.agents: - model = self.model - el_costs = model.election_costs - # Give agents their (new) known fields - agent.update_known_cells(area=self) - if (agent.assets >= el_costs - and agent.ask_for_participation(area=self)): - agent.num_elections_participated += 1 - # Collect the participation fee - agent.assets = agent.assets - el_costs - # Ask the agent for her preference - preference_profile.append(agent.vote(area=self)) - # agent.vote returns an array containing dissatisfaction values - # between 0 and 1 for each option, interpretable as rank values. - return np.array(preference_profile) - - def _distribute_rewards(self) -> None: - """ - Calculates and distributes rewards (or penalties) to agents based on outcomes. - - The function measures the difference between the actual color distribution - and the elected outcome using a distance metric. It then increments or reduces - agent assets accordingly, ensuring assets do not fall below zero. - """ - model = self.model - # Calculate the distance to the real distribution using distance_func - real_color_ord = np.argsort(self.color_distribution)[::-1] # Descending - dist_func = model.distance_func - self._dist_to_reality = dist_func(real_color_ord, self.voted_ordering, - model.color_search_pairs) - # Calculate the rpa - rewards per agent (can be negative) - rpa = (0.5 - self.dist_to_reality) * model.max_reward # TODO: change this (?) - # Distribute the two types of rewards - color_search_pairs = model.color_search_pairs - for a in self.agents: - # Personality-based reward factor - p = dist_func(a.personality, real_color_ord, color_search_pairs) - # + common reward (reward_pa) for all agents - a.assets = int(a.assets + (0.5 - p) * model.max_reward + rpa) - if a.assets < 0: # Correct wealth if it fell below zero - a.assets = 0 - - def _update_color_distribution(self) -> None: - """ - Recalculates the area's color distribution and updates the _color_distribution attribute. - - This method counts how many cells of each color belong to the area, normalizes - the counts by the total number of cells, and stores the result internally. - """ - color_count = {} - for cell in self.cells: - color = cell.color - color_count[color] = color_count.get(color, 0) + 1 - for color in range(self.model.num_colors): - dist_val = color_count.get(color, 0) / self.num_cells # Float - self._color_distribution[color] = dist_val - - def _filter_cells(self, cell_list): - """ - This method is used to filter a given list of cells to return only - those which are within the area. - - Args: - cell_list: A list of ColorCell cells to be filtered. - - Returns: - A list of ColorCell cells that are within the area. - """ - cell_set = set(self.cells) - return [c for c in cell_list if c in cell_set] - - def step(self) -> None: - """ - Run one step of the simulation. - - Conduct an election in the area, - mutate the cells' colors according to the election outcome - and update the color distribution of the area. - """ - self._voter_turnout = self._conduct_election() # The main election logic! - if self.voter_turnout == 0: - return # TODO: What to do if no agent participated..? - - # Mutate colors in cells - # Take some number of cells to mutate (i.e., 5 %) - n_to_mutate = int(self.model.mu * self.num_cells) - # TODO/Idea: What if the voter_turnout determines the mutation rate? - # randomly select x cells - cells_to_mutate = self.random.sample(self.cells, n_to_mutate) - # Use voted ordering to pick colors in descending order - # To pre-select colors for all cells to mutate - # TODO: Think about this: should we take local color-structure - # into account - like in color patches - to avoid colors mutating into - # very random structures? # Middendorf - colors = np.random.choice(self.voted_ordering, size=n_to_mutate, - p=self.model.color_probs) - # Assign the newly selected colors to the cells - for cell, color in zip(cells_to_mutate, colors): - cell.color = color - # Important: Update the color distribution (because colors changed) - self._update_color_distribution() - - -def compute_collective_assets(model): - sum_assets = sum(agent.assets for agent in model.voting_agents) - return sum_assets - - -def compute_gini_index(model): - # TODO: separate to be able to calculate it zone-wise as well as globally - # TODO: Unit-test this function - # Extract the list of assets for all agents - assets = [agent.assets for agent in model.voting_agents] - n = len(assets) - if n == 0: - return 0 # No agents, no inequality - # Sort the assets - sorted_assets = sorted(assets) - # Calculate the Gini Index - cumulative_sum = sum((i + 1) * sorted_assets[i] for i in range(n)) - total_sum = sum(sorted_assets) - if total_sum == 0: - return 0 # No agent has any assets => view as total equality - gini_index = (2 * cumulative_sum) / (n * total_sum) - (n + 1) / n - return int(gini_index * 100) # Return in "percent" (0-100) - - -def get_voter_turnout(model): - voter_turnout_sum = 0 - num_areas = model.num_areas - for area in model.areas: - voter_turnout_sum += area.voter_turnout - if not model.global_area is None: - # TODO: Check the correctness and whether it makes sense to include the global area here - voter_turnout_sum += model.global_area.voter_turnout - num_areas += 1 - elif num_areas == 0: - return 0 - return voter_turnout_sum / num_areas - - -def create_personality(num_colors): - """ NOT USED - Creates and returns a list of 'personalities' that are to be assigned - to agents. Each personality is a NumPy array of length 'num_colors' - but it is not a full ranking vector since the number of colors influencing - the personality is limited. The array is therefore not normalized. - White (color 0) is never part of a personality. - - Args: - num_colors: The number of colors in the simulation. - """ - # TODO add unit tests for this function - personality = np.random.randint(0, 100, num_colors) # TODO low=0 or 1? - # Save the sum to "normalize" the values later (no real normalization) - sum_value = sum(personality) + 1e-8 # To avoid division by zero - # Select only as many features as needed (num_personality_colors) - # to_del = num_colors - num_personality_colors # How many to be deleted - # if to_del > 0: - # # The 'replace=False' ensures that indexes aren't chosen twice - # indices = np.random.choice(num_colors, to_del, replace=False) - # personality[indices] = 0 # 'Delete' the values - personality[0] = 0 # White is never part of the personality - # "Normalize" the rest of the values - personality = personality / sum_value - return personality - - -def get_color_distribution_function(color): - """ - This method returns a lambda function for the color distribution chart. - - Args: - color: The color number (used as index). - """ - return lambda m: m.av_area_color_dst[color] - - -def get_area_voter_turnout(area): - if isinstance(area, Area): - return area.voter_turnout - return None - -def get_area_dist_to_reality(area): - if isinstance(area, Area): - return area.dist_to_reality - return None - -def get_area_color_distribution(area): - if isinstance(area, Area): - return area.color_distribution.tolist() - return None - -def get_election_results(area): - """ - Returns the voted ordering as a list or None if not available. - - Returns: - List of voted ordering or None. - """ - if isinstance(area, Area) and area.voted_ordering is not None: - return area.voted_ordering.tolist() - return None - - class CustomScheduler(mesa.time.BaseScheduler): def step(self): """ @@ -518,7 +93,6 @@ class ParticipationModel(mesa.Model): (metrics and statistics) at each simulation step. scheduler (CustomScheduler): The scheduler responsible for executing the step function. - draw_borders (bool): Only for visualization (no effect on simulation). _preset_color_dst (ndarray): A predefined global color distribution (set randomly) that affects cell initialization globally. """ @@ -526,10 +100,15 @@ class ParticipationModel(mesa.Model): def __init__(self, height, width, num_agents, num_colors, num_personalities, mu, election_impact_on_mutation, common_assets, known_cells, num_areas, av_area_height, av_area_width, area_size_variance, - patch_power, color_patches_steps, draw_borders, heterogeneity, - rule_idx, distance_idx, election_costs, max_reward, - show_area_stats): + patch_power, color_patches_steps, heterogeneity, + rule_idx, distance_idx, election_costs, max_reward, seed=None): super().__init__() + if seed is not None: + self.random.seed(seed) # Mesa RNG (Pythons random.Random + self.np_random = np.random.default_rng(seed) # Central NumPy RNG + np.random.seed(seed) # For any legacy/global Numpy calls + else: + self.np_random = np.random.default_rng() # TODO clean up class (public/private variables) self.height = height self.width = width @@ -543,7 +122,6 @@ def __init__(self, height, width, num_agents, num_colors, num_personalities, # Random bias factors that affect the initial color distribution self._vertical_bias = self.random.uniform(0, 1) self._horizontal_bias = self.random.uniform(0, 1) - self.draw_borders = draw_borders # Color distribution (global) self._preset_color_dst = self.create_color_distribution(heterogeneity) self._av_area_color_dst = self._preset_color_dst @@ -563,15 +141,15 @@ def __init__(self, height, width, num_agents, num_colors, num_personalities, self.search_pairs = list(combinations(range(0, self.options.size), 2)) # TODO check if correct! self.option_vec = np.arange(self.options.size) # Also to speed up self.color_search_pairs = list(combinations(range(0, num_colors), 2)) - # Create color cells + # Create color cells (IDs start after areas+agents) self.color_cells: List[Optional[ColorCell]] = [None] * (height * width) - self._initialize_color_cells() - # Create agents + self._initialize_color_cells(id_start=num_agents + num_areas) + # Create voting agents (IDs start after areas) # TODO: Where do the agents get there known cells from and how!? self.voting_agents: List[Optional[VoteAgent]] = [None] * num_agents self.personalities = self.create_personalities(num_personalities) self.personality_distribution = self.pers_dist(num_personalities) - self.initialize_voting_agents() + self.initialize_voting_agents(id_start=num_areas) # Area variables self.global_area = self.initialize_global_area() # TODO create bool variable to make this optional self.areas: List[Optional[Area]] = [None] * num_areas @@ -586,76 +164,84 @@ def __init__(self, height, width, num_agents, num_colors, num_personalities, self.datacollector = self.initialize_datacollector() # Collect initial data self.datacollector.collect(self) - # Statistics - self.show_area_stats = show_area_stats @property - def num_colors(self): + def num_colors(self) -> int: return len(self.colors) @property - def av_area_color_dst(self): + def av_area_color_dst(self) -> np.ndarray: return self._av_area_color_dst @av_area_color_dst.setter - def av_area_color_dst(self, value): + def av_area_color_dst(self, value) -> None: self._av_area_color_dst = value @property - def num_agents(self): + def num_agents(self) -> int: return len(self.voting_agents) @property - def num_areas(self): + def num_areas(self) -> int: return len(self.areas) @property - def preset_color_dst(self): - return len(self._preset_color_dst) + def preset_color_dst(self) -> np.ndarray: + return self._preset_color_dst - def _initialize_color_cells(self): + def _initialize_color_cells(self, id_start=0) -> None: """ - This method initializes a color cells for each cell in the model's grid. + Initialize one ColorCell per grid cell. + Args: + id_start (int): The starting ID to ensure unique IDs. """ # Create a color cell for each cell in the grid - for unique_id, (_, (row, col)) in enumerate(self.grid.coord_iter()): + for idx, (_, (row, col)) in enumerate(self.grid.coord_iter()): + # Assign unique ID after areas and agents + unique_id = id_start + idx # The colors are chosen by a predefined color distribution color = self.color_by_dst(self._preset_color_dst) - # Create the cell + # Create the cell (skip ids for area and voting agents) cell = ColorCell(unique_id, self, (row, col), color) # Add it to the grid self.grid.place_agent(cell, (row, col)) # Add the color cell to the scheduler #self.scheduler.add(cell) # TODO: check speed diffs using this.. # And to the 'model.color_cells' list (for faster access) - self.color_cells[unique_id] = cell # TODO: check if its not better to simply use the grid when finally changing the grid type to SingleGrid + self.color_cells[idx] = cell # TODO: check if its not better to simply use the grid when finally changing the grid type to SingleGrid - def initialize_voting_agents(self): + def initialize_voting_agents(self, id_start=0) -> None: """ This method initializes as many voting agents as set in the model with a randomly chosen personality. It places them randomly on the grid. It also ensures that each agent is assigned to the color cell it is standing on. + Args: + id_start (int): The starting ID for agents to ensure unique IDs. """ dist = self.personality_distribution - rng = np.random.default_rng() assets = self.common_assets // self.num_agents - for a_id in range(self.num_agents): + for idx in range(self.num_agents): + # Assign unique ID after areas + unique_id = id_start + idx # Get a random position x = self.random.randrange(self.width) y = self.random.randrange(self.height) - personality = rng.choice(self.personalities, p=dist) + # Choose a personality based on the distribution + nr = len(self.personalities) + personality_idx = self.np_random.choice(nr, p=dist) + personality = self.personalities[personality_idx] # Create agent without appending (add to the pre-defined list) - agent = VoteAgent(a_id, self, (x, y), personality, - assets=assets, add=False) # TODO: initial assets?! - self.voting_agents[a_id] = agent # Add using the index (faster) - # Add the agent to the grid by placing it on a cell + agent = VoteAgent(unique_id, self, (x, y), personality, + personality_idx, assets=assets, add=False) # TODO: initial assets?! + self.voting_agents[idx] = agent # Add using the index (faster) + # Add the agent to the grid by placing it on a ColorCell cell = self.grid.get_cell_list_contents([(x, y)])[0] if TYPE_CHECKING: cell = cast(ColorCell, cell) cell.add_agent(agent) - def init_color_probs(self, election_impact): + def init_color_probs(self, election_impact) -> np.ndarray: """ This method initializes a probability array for the mutation of colors. The probabilities reflect the election outcome with some impact factor. @@ -668,7 +254,7 @@ def init_color_probs(self, election_impact): p = p / sum(p) return p - def initialize_area(self, a_id: int, x_coord, y_coord): + def initialize_area(self, a_id: int, x_coord, y_coord) -> None: """ This method initializes one area in the models' grid. """ @@ -695,14 +281,8 @@ def initialize_all_areas(self) -> None: are placed randomly on the grid to ensure that `num_areas` areas are initialized. - Args: - None. - - Returns: - None. initializes `num_areas` and places them directly on the grid. - - Raises: - None, but if `self.num_areas == 0`, the method exits early. + Initializes `num_areas` and places them directly on the grid. + But if `self.num_areas == 0`, the method exits early. Example: - Given `num_areas = 4` and `grid.width = grid.height = 10`, @@ -714,15 +294,11 @@ def initialize_all_areas(self) -> None: if self.num_areas == 0: return # Calculate the number of areas in each direction - roo_apx = round(sqrt(self.num_areas)) nr_areas_x = self.grid.width // self.av_area_width - nr_areas_y = self.grid.width // self.av_area_height + nr_areas_y = self.grid.height // self.av_area_height # Calculate the distance between the areas - area_x_dist = self.grid.width // roo_apx - area_y_dist = self.grid.height // roo_apx - print(f"roo_apx: {roo_apx}, nr_areas_x: {nr_areas_x}, " - f"nr_areas_y: {nr_areas_y}, area_x_dist: {area_x_dist}, " - f"area_y_dist: {area_y_dist}") # TODO rm print + area_x_dist = self.grid.width // nr_areas_x + area_y_dist = self.grid.height // nr_areas_y x_coords = range(0, self.grid.width, area_x_dist) y_coords = range(0, self.grid.height, area_y_dist) # Add additional areas if necessary (num_areas not a square number) @@ -744,9 +320,9 @@ def initialize_all_areas(self) -> None: self.initialize_area(next(a_ids), x_coord, y_coord) - def initialize_global_area(self): + def initialize_global_area(self) -> Area: """ - This method initializes the global area spanning the whole grid. + Initializes the global area spanning the whole grid. Returns: Area: The global area (with unique_id set to -1 and idx to (0, 0)). @@ -758,16 +334,15 @@ def initialize_global_area(self): return global_area - def create_personalities(self, n: int): + def create_personalities(self, n: int) -> np.ndarray: """ - Creates n unique "personalities," where a "personality" is a specific - permutation of self.num_colors color indices. + Creates n unique personalities as permutations of color indices. Args: - n (int): Number of unique personalities to generate. + n (int): Number of unique personalities. Returns: - np.ndarray: Array of shape `(n, num_colors)`. + np.ndarray: Shape `(n, num_colors)`. Raises: ValueError: If `n` exceeds the possible unique permutations. @@ -779,7 +354,7 @@ def create_personalities(self, n: int): [2, 1, 0]] """ # p_colors = range(1, self.num_colors) # Personalities exclude white - max_permutations = np.math.factorial(self.num_colors) + max_permutations = factorial(self.num_colors) if n > max_permutations or n < 1: raise ValueError(f"Cannot generate {n} unique personalities: " f"only {max_permutations} unique ones exist.") @@ -793,7 +368,7 @@ def create_personalities(self, n: int): return np.array(list(selected_permutations)) - def initialize_datacollector(self): + def initialize_datacollector(self) -> mesa.DataCollector: color_data = {f"Color {i}": get_color_distribution_function(i) for i in range(self.num_colors)} return mesa.DataCollector( @@ -801,7 +376,8 @@ def initialize_datacollector(self): "Collective assets": compute_collective_assets, "Gini Index (0-100)": compute_gini_index, "Voter turnout globally (in percent)": get_voter_turnout, - **color_data + **color_data, + "GridColors": get_grid_colors }, agent_reporters={ # "Voter Turnout": lambda a: a.voter_turnout if isinstance(a, Area) else None, @@ -852,7 +428,7 @@ def adjust_color_pattern(self, color_patches_steps: int, patch_power: float): cell.color = most_common_color - def create_color_distribution(self, heterogeneity: float): + def create_color_distribution(self, heterogeneity: float) -> np.ndarray: """ This method is used to create a color distribution that has a bias according to the given heterogeneity factor. @@ -868,19 +444,19 @@ def create_color_distribution(self, heterogeneity: float): return dst_array - def color_patches(self, cell: ColorCell, patch_power: float): + def color_patches(self, cell: ColorCell, patch_power: float) -> int: """ - This method is used to create a less random initial color distribution + Meant to create a less random initial color distribution using a similar logic to the color patches model. It uses a (normalized) bias coordinate to center the impact of the color patches structures impact around. Args: - cell: The cell that may change its color accordingly - patch_power: Like a radius of impact around the bias point. + cell (ColorCell): The cell possibly changing color. + patch_power (float): Radius-like impact around bias point. Returns: - int: The consensus color or the cell's own color if no consensus. + int: Consensus color or the cell's own color if no consensus. """ # Calculate the normalized position of the cell normalized_x = cell.row / self.height @@ -922,7 +498,7 @@ def update_av_area_color_dst(self): @staticmethod - def pers_dist(size): + def pers_dist(size: int) -> np.ndarray: """ This method creates a normalized normal distribution array for picking and depicting the distribution of personalities in the model. @@ -931,9 +507,10 @@ def pers_dist(size): size: The mean value of the normal distribution. Returns: - np.array: Normalized (sum is one) array mimicking a gaussian curve. + np.ndarray: Normalized (sum is one) array mimicking a gaussian curve. """ # Generate a normal distribution + # TODO: Change to model or global rng?!!! rng = np.random.default_rng() dist = rng.normal(0, 1, size) dist.sort() # To create a gaussian curve like array @@ -944,7 +521,7 @@ def pers_dist(size): @staticmethod - def create_all_options(n: int, include_ties=False): + def create_all_options(n: int, include_ties=False) -> np.ndarray: """ Creates a matrix (an array of all possible ranking vectors), if specified including ties. @@ -955,7 +532,7 @@ def create_all_options(n: int, include_ties=False): include_ties (bool): If True, rankings include ties. Returns: - np.array: A matrix containing all possible ranking vectors. + np.ndarray: A matrix containing all possible ranking vectors. """ if include_ties: # Create all possible combinations and sort out invalid rankings @@ -967,7 +544,7 @@ def create_all_options(n: int, include_ties=False): return r @staticmethod - def color_by_dst(color_distribution: np.array) -> int: + def color_by_dst(color_distribution: np.ndarray) -> int: """ Selects a color (int) from range(len(color_distribution)) based on the given color_distribution array, where each entry represents @@ -985,7 +562,7 @@ def color_by_dst(color_distribution: np.array) -> int: """ if abs(sum(color_distribution) -1) > 1e-8: raise ValueError("The color_distribution array must sum to 1.") - r = np.random.random() # Random float between 0 and 1 + r = np.random.random() # Float betw. 0 and 1 cumulative_sum = 0.0 for color_idx, prob in enumerate(color_distribution): if prob < 0: @@ -996,3 +573,41 @@ def color_by_dst(color_distribution: np.array) -> int: # This point should never be reached. raise ValueError("Unexpected error in color_distribution.") + + +def get_color_distribution_function(color: int) -> Callable[ + [ParticipationModel], float]: + """ + Returns a lambda to extract a single color's distribution from the model. + + Args: + color (int): Index of the color. + + Returns: + Callable[[ParticipationModel], float]: Extractor. + """ + return lambda m: float(m.av_area_color_dst[color]) + + +def get_area_voter_turnout(area: Area) -> Optional[float]: + return area.voter_turnout if isinstance(area, Area) else None + + +def get_area_dist_to_reality(area: Area) -> Optional[float]: + return area.dist_to_reality if isinstance(area, Area) else None + + +def get_area_color_distribution(area: Area) -> Optional[list[float]]: + return area.color_distribution.tolist() if isinstance(area, Area) else None + + +def get_election_results(area: Area) -> Optional[list[int]]: + """ + Returns the voted ordering as a list or None if not available. + + Returns: + list[int] | None + """ + if isinstance(area, Area) and area.voted_ordering is not None: + return area.voted_ordering.tolist() + return None diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/democracy_sim/distance_functions.py b/src/utils/distance_functions.py similarity index 72% rename from democracy_sim/distance_functions.py rename to src/utils/distance_functions.py index 90af2fa..0c4abc2 100644 --- a/democracy_sim/distance_functions.py +++ b/src/utils/distance_functions.py @@ -1,64 +1,22 @@ from math import comb import numpy as np from numpy.typing import NDArray -from typing import TypeAlias +from typing import TypeAlias, Sequence +IntArray: TypeAlias = NDArray[np.int64] FloatArray: TypeAlias = NDArray[np.float64] -def kendall_tau_on_ranks(rank_arr_1, rank_arr_2, search_pairs, color_vec): - """ - DON'T USE - (don't use this for orderings!) - - This function calculates the kendal tau distance between two rank vektors. - (The Kendall tau rank distance is a metric that counts the number - of pairwise disagreements between two ranking lists. - The larger the distance, the more dissimilar the two lists are. - Kendall tau distance is also called bubble-sort distance). - Rank vectors hold the rank of each option (option = index). - Not to be confused with an ordering (or sequence) where the vector - holds options and the index is the rank. - - Args: - rank_arr_1: First (NumPy) array containing the ranks of each option - rank_arr_2: The second rank array - search_pairs: The pairs of indices (for efficiency) - color_vec: The vector of colors (for efficiency) - - Returns: - The kendall tau distance - """ - # Get the ordering (option names being 0 to length) - ordering_1 = np.argsort(rank_arr_1) - ordering_2 = np.argsort(rank_arr_2) - # print("Ord1:", list(ordering_1), " Ord2:", list(ordering_2)) - # Create the mapping array - mapping_array = np.empty_like(ordering_1) # Empty array with same shape - mapping_array[ordering_1] = color_vec # Fill the mapping - # Use the mapping array to rename elements in ordering_2 - renamed_arr_2 = mapping_array[ordering_2] # Uses NumPys advanced indexing - # print("Ren1:",list(range(len(color_vec))), " Ren2:", list(renamed_arr_2)) - # Count inversions using precomputed pairs - kendall_distance = 0 - # inversions = [] - for i, j in search_pairs: - if renamed_arr_2[i] > renamed_arr_2[j]: - # inversions.append((renamed_arr_2[i], renamed_arr_2[j])) - kendall_distance += 1 - # print("Inversions:\n", inversions) - return kendall_distance - - -def unnormalized_kendall_tau(ordering_1, ordering_2, search_pairs): +def unnormalized_kendall_tau(ordering_1: IntArray, ordering_2: IntArray, + search_pairs: Sequence[tuple[int, int]]) -> int: """ This function calculates the kendal tau distance on two orderings. An ordering holds the option names in the order of their rank (rank=index). Args: - ordering_1: First (NumPy) array containing ranked options - ordering_2: The second ordering array - search_pairs: Containing search pairs of indices (for efficiency) + ordering_1 (IntArray): First Array containing ranked options. + ordering_2 (IntArray): The second ordering array. + search_pairs (Sequence[tuple[int,int]]): Index pairs (for efficiency). Returns: The kendall tau distance @@ -74,9 +32,11 @@ def unnormalized_kendall_tau(ordering_1, ordering_2, search_pairs): return kendall_distance -def kendall_tau(ordering_1, ordering_2, search_pairs): +def kendall_tau(ordering_1: IntArray, ordering_2: IntArray, + search_pairs: Sequence[tuple[int, int]]) -> float: """ - This calculates the normalized Kendall tau distance of two orderings. + Calculate the unnormalized Kendall tau distance between two orderings. + The Kendall tau rank distance is a metric that counts the number of pairwise disagreements between two ranking lists. The larger the distance, the more dissimilar the two lists are. @@ -84,12 +44,12 @@ def kendall_tau(ordering_1, ordering_2, search_pairs): An ordering holds the option names in the order of their rank (rank=index). Args: - ordering_1: First (NumPy) array containing ranked options - ordering_2: The second ordering array - search_pairs: Containing the pairs of indices (for efficiency) + ordering_1 (IntArray): First (NumPy) array containing ranked options. + ordering_2 (IntArray): The second ordering array. + search_pairs (Sequence[tuple[int,int]]): Index pairs. Returns: - The kendall tau distance + int: Normalized kendall tau distance """ # TODO: remove these tests (comment out) on actual simulations to speed up n = ordering_1.size @@ -109,35 +69,7 @@ def kendall_tau(ordering_1, ordering_2, search_pairs): return normalized_distance -def spearman_distance(rank_arr_1, rank_arr_2): - """ - Beware: don't use this for orderings! - - This function calculates the Spearman distance between two rank vektors. - Spearman's foot rule is a measure of the distance between ranked lists. - It is given as the sum of the absolute differences between the ranks - of the two lists. - This function is meant to work with numeric values as well. - Hence, we only assume the rank values to be comparable (e.q. normalized). - - Args: - rank_arr_1: First (NumPy) array containing the ranks of each option - rank_arr_2: The second rank array - - Returns: - The Spearman distance - """ - # TODO: remove these tests (comment out) on actual simulations - assert rank_arr_1.size == rank_arr_2.size, \ - "Rank arrays must have the same length" - if rank_arr_1.size > 0: - assert (rank_arr_1.min() == rank_arr_2.min() - and rank_arr_1.max() == rank_arr_2.max()), \ - f"Error: Sequences {rank_arr_1}, {rank_arr_2} aren't comparable." - return np.sum(np.abs(rank_arr_1 - rank_arr_2)) - - -def spearman(ordering_1, ordering_2, _search_pairs=None): +def spearman(ordering_1: IntArray, ordering_2: IntArray, _search_pairs=None) -> float: """ This calculates the normalized Spearman distance between two orderings. Spearman's foot rule is a measure of the distance between ranked lists. @@ -150,7 +82,7 @@ def spearman(ordering_1, ordering_2, _search_pairs=None): _search_pairs: This parameter is intentionally unused. Returns: - The Spearman distance + float: Spearman distance """ # TODO: remove these tests (comment out) on actual simulations to speed up n = ordering_1.size @@ -166,3 +98,82 @@ def spearman(ordering_1, ordering_2, _search_pairs=None): else: # Odd number of elements max_dist = n * (n - 1) / 2 return distance / max_dist + + +# Distance functions for rank vectors (not orderings) +# (Rank vectors hold the rank of each option (option = index). +# Not to be confused with an ordering (or sequence) where the vector +# holds options and the index is the rank.) + + +def kendall_tau_on_ranks(rank_arr_1: FloatArray, rank_arr_2: FloatArray, + search_pairs: Sequence[tuple[int, int]], + color_vec: IntArray) -> int: + """ + Beware: don't use this for orderings! + + This function calculates the kendal tau distance between two rank vektors. + (The Kendall tau rank distance is a metric that counts the number + of pairwise disagreements between two ranking lists. + The larger the distance, the more dissimilar the two lists are. + Kendall tau distance is also called bubble-sort distance). + Rank vectors hold the rank of each option (option = index). + Not to be confused with an ordering (or sequence) where the vector + holds options and the index is the rank. + + Args: + rank_arr_1 (FloatArray): First Array containing the ranks of each option. + rank_arr_2 (FloatArray): The second rank array. + search_pairs (Sequence[tuple[int, int]]): The pairs of indices. + color_vec: (IntArray): The vector of colors (for efficiency). + + Returns: + int: Kendall tau distance. + """ + # Get the ordering (option names being 0 to length) + ordering_1 = np.argsort(rank_arr_1) + ordering_2 = np.argsort(rank_arr_2) + # print("Ord1:", list(ordering_1), " Ord2:", list(ordering_2)) + # Create the mapping array + mapping_array = np.empty_like(ordering_1) # Empty array with same shape + mapping_array[ordering_1] = color_vec # Fill the mapping + # Use the mapping array to rename elements in ordering_2 + renamed_arr_2 = mapping_array[ordering_2] # Uses NumPys advanced indexing + # print("Ren1:",list(range(len(color_vec))), " Ren2:", list(renamed_arr_2)) + # Count inversions using precomputed pairs + kendall_distance = 0 + # inversions = [] + for i, j in search_pairs: + if renamed_arr_2[i] > renamed_arr_2[j]: + # inversions.append((renamed_arr_2[i], renamed_arr_2[j])) + kendall_distance += 1 + # print("Inversions:\n", inversions) + return kendall_distance + + +def spearman_distance(rank_arr_1: FloatArray, rank_arr_2: FloatArray) -> float: + """ + Beware: don't use this for orderings! + + This function calculates the Spearman distance between two rank vektors. + Spearman's foot rule is a measure of the distance between ranked lists. + It is given as the sum of the absolute differences between the ranks + of the two lists. + This function is meant to work with numeric values as well. + Hence, we only assume the rank values to be comparable (e.q. normalized). + + Args: + rank_arr_1 (FloatArray): Array containing the ranks of each option + rank_arr_2 (FloatArray): The second rank array. + + Returns: + float: The Spearman distance + """ + # TODO: remove these tests (comment out) on actual simulations + assert rank_arr_1.size == rank_arr_2.size, \ + "Rank arrays must have the same length" + if rank_arr_1.size > 0: + assert (rank_arr_1.min() == rank_arr_2.min() + and rank_arr_1.max() == rank_arr_2.max()), \ + f"Error: Sequences {rank_arr_1}, {rank_arr_2} aren't comparable." + return np.sum(np.abs(rank_arr_1 - rank_arr_2)) diff --git a/src/utils/metrics.py b/src/utils/metrics.py new file mode 100644 index 0000000..edb021a --- /dev/null +++ b/src/utils/metrics.py @@ -0,0 +1,53 @@ + +def get_grid_colors(model): + """ + Returns the current grid state as a list of rows. + Each row is a list of cell colors. Assumes that the cells were + created in row-major order and stored in model.color_cells. + """ + grid = [] + for row in range(model.height): + start = row * model.width + end = start + model.width + # Get the color for each cell in the row. + row_colors = [model.color_cells[i].color for i in range(start, end)] + grid.append(row_colors) + return grid + + +def compute_collective_assets(model): + sum_assets = sum(agent.assets for agent in model.voting_agents) + return sum_assets + + +def compute_gini_index(model): + # TODO: separate to be able to calculate it zone-wise as well as globally + # TODO: Unit-test this function + # Extract the list of assets for all agents + assets = [agent.assets for agent in model.voting_agents] + n = len(assets) + if n == 0: + return 0 # No agents, no inequality + # Sort the assets + sorted_assets = sorted(assets) + # Calculate the Gini Index + cumulative_sum = sum((i + 1) * sorted_assets[i] for i in range(n)) + total_sum = sum(sorted_assets) + if total_sum == 0: + return 0 # No agent has any assets => view as total equality + gini_index = (2 * cumulative_sum) / (n * total_sum) - (n + 1) / n + return int(gini_index * 100) # Return in "percent" (0-100) + + +def get_voter_turnout(model): + voter_turnout_sum = 0 + num_areas = model.num_areas + for area in model.areas: + voter_turnout_sum += area.voter_turnout + if not model.global_area is None: + # TODO: Check the correctness and whether it makes sense to include the global area here + voter_turnout_sum += model.global_area.voter_turnout + num_areas += 1 + elif num_areas == 0: + return 0 + return voter_turnout_sum / num_areas diff --git a/democracy_sim/social_welfare_functions.py b/src/utils/social_welfare_functions.py similarity index 75% rename from democracy_sim/social_welfare_functions.py rename to src/utils/social_welfare_functions.py index ff933f1..d89a669 100644 --- a/democracy_sim/social_welfare_functions.py +++ b/src/utils/social_welfare_functions.py @@ -7,21 +7,21 @@ and the values (each in [0,1]) are normalized ranking values. The purpose of this is to allow for non-discrete and non-equidistant rankings. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import numpy as np -def complete_ranking(ranking: np.array, num_options: int): +def complete_ranking(ranking: np.ndarray, num_options: int) -> np.ndarray: """ This function adds options that are not in the ranking in a random order. Args: - ranking: The ranking to be completed with the missing options. - num_options: The total number of options. + ranking (nd.ndarray): Partial ranking of option indices. + num_options (int): The total number of options. Returns: - The completed ranking. + np.ndarray: Completed ranking of length `num_options`. """ all_options = np.arange(num_options) mask = np.isin(all_options, ranking, invert=True) @@ -29,7 +29,9 @@ def complete_ranking(ranking: np.array, num_options: int): np.random.shuffle(non_included_options) return np.concatenate((ranking, non_included_options)) -def run_tie_breaking_preparation_for_majority(pref_table, noise_factor=100): +def run_tie_breaking_preparation_for_majority(pref_table: np.ndarray, + noise_factor: int = 100 + ) -> np.ndarray: """ This function prepares the preference table for majority rule such that it handles ties in the voters' preferences. @@ -37,11 +39,11 @@ def run_tie_breaking_preparation_for_majority(pref_table, noise_factor=100): The tie breaking is randomized to ensure anonymity and neutrality. Args: - pref_table: The agent's preferences. - noise_factor: Influences the amount of noise to be added + pref_table (np.ndarray): Preferences per agent (rows) per option (cols). + noise_factor (int): Controls noise magnitude. Returns: - The preference table without ties for first choices. + np.ndarray: Table without ties in first choices. """ # Add some random noise to break ties (based on the variances) variances = np.var(pref_table, axis=1) @@ -69,17 +71,18 @@ def run_tie_breaking_preparation_for_majority(pref_table, noise_factor=100): # Put the parts back together return np.concatenate((pref_tab_var_non_zero, pref_tab_var_zero)) -def majority_rule(pref_table): +def majority_rule(pref_table: np.ndarray) -> np.ndarray: """ This function implements the majority rule social welfare function. Beware: Input is a preference table (values define a ranking, index=option), but the output is a ranking/an ordering (values represent options). Args: - pref_table: The agent's preferences (disagreement) as a NumPy matrix + pref_table (np.ndarray): Preferences (disagreement values) + per agent (rows) per option (cols). Returns: - The resulting preference ranking (beware: its not a pref. relation) + np.ndarray: Resulting preference ranking (beware: not a pref. relation) """ n, m = pref_table.shape # n agents, m options # Break ties if they exist @@ -106,8 +109,11 @@ def majority_rule(pref_table): ranking = complete_ranking(ranking, m) return ranking -def preprocessing_for_approval(pref_table, threshold=None): +def preprocessing_for_approval(pref_table: np.ndarray, + threshold: Optional[float] = None) -> np.ndarray: """ + Interpret values below threshold as approval. + This function prepares the preference table for approval voting by interpreting every value below a threshold as an approval. Beware: the values are distance/disagreement => smaller = less disagreement @@ -119,27 +125,27 @@ def preprocessing_for_approval(pref_table, threshold=None): can still vary depending on the specific values in the preference table. Args: - pref_table: The agent's preferences. - threshold: The threshold for approval. + pref_table (np.ndarray): Preferences table. + threshold (float | None): Approval threshold; defaults to 1/m. Returns: - The preference table with the options approved or not. + np.ndarray: Binary approvals with shape of `pref_table`. """ if threshold is None: threshold = 1 / pref_table.shape[1] return (pref_table < threshold).astype(int) -def imp_prepr_for_approval(pref_table): +def imp_prepr_for_approval(pref_table: np.ndarray) -> np.ndarray: """ This is just like preprocessing_for_approval, but more intelligent. It sets the threshold depending on the variances. Args: - pref_table: The agent's preferences. + pref_table (np.ndarray): Preferences table. Returns: - The preference table with the options approved or not. + np.ndarray: Binary approvals with shape of `pref_table`. """ # The threshold is set according to the variances threshold = np.mean(pref_table, axis=1) - np.var(pref_table, axis=1) @@ -148,18 +154,19 @@ def imp_prepr_for_approval(pref_table): return (pref_table < threshold.reshape(-1, 1)).astype(int) -def approval_voting(pref_table): - """ TODO: does this take the meaning of the values into account? value = dist. = disagreement ! +def approval_voting(pref_table: np.ndarray) -> np.ndarray: + """ This function implements the approval voting social welfare function. Beware: Input is a preference table (values define a ranking, index=option), but the output is a ranking/an ordering (values represent options). Args: - pref_table: The agent's preferences (disagreement) as a NumPy matrix + pref_table (np.ndarray): Agent's preferences (disagreement) as matrix. Returns: - The resulting preference ranking (beware: not a pref. relation). + np.ndarray: Resulting preference ranking (beware: not a pref. relation). """ + # TODO: does this take the meaning of the values into account? value = dist. = disagreement ! pref_table = imp_prepr_for_approval(pref_table) # Count how often each option is approved approval_counts = np.sum(pref_table, axis=0) @@ -172,19 +179,19 @@ def approval_voting(pref_table): return np.argsort(-(approval_counts + noise)) # TODO: check order (ascending/descending) - np.argsort sorts ascending -def continuous_score_voting(pref_table): +def continuous_score_voting(pref_table: np.ndarray) -> np.ndarray: """ - TODO: integrate and test This function implements a continuous score voting based on disagreement. Beware: Input is a preference table (values define a ranking, index=option), but the output is a ranking/an ordering (values represent options). Args: - pref_table: The agent's preferences (disagreement) as a NumPy matrix + pref_table (np.ndarray): Agent's preferences (disagreement) as matrix. Returns: - The resulting preference ranking (beware: not a pref. relation). + np.ndarray: Resulting preference ranking (beware: not a pref. relation). """ + # TODO: integrate and test # Sum up the disagreement for each option scores = np.sum(pref_table, axis=0) # Add noise to break ties diff --git a/src/viz/__init__.py b/src/viz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/viz/factory.py b/src/viz/factory.py new file mode 100644 index 0000000..436e444 --- /dev/null +++ b/src/viz/factory.py @@ -0,0 +1,135 @@ +from __future__ import annotations +from src.config.schema import VisualizationConfig +from src.config.loader import AppConfig +from mesa.visualization.modules import CanvasGrid, ChartModule +from src.agents.color_cell import ColorCell + +# Central color palette (index-aligned with color IDs) +COLORS = [ + "LightGray", # 0 + "Red", # 1 + "Green", # 2 + "Blue", # 3 + "Yellow", # 4 + "Aqua", # 5 + "Fuchsia", # 6 + "Lime", # 7 + "Maroon", # 8 + "Orange", # 9 +] + +# Module-level store for visualization config +_VIS_CFG: VisualizationConfig | None = None + + +def get_vis_cfg() -> VisualizationConfig | None: + return _VIS_CFG + + +def make_canvas(cfg: AppConfig) -> CanvasGrid: + """ + Build a CanvasGrid using the current config. + Expects cfg like: { 'model': {...}, 'visualization': {...} }. + """ + global _VIS_CFG + model_cfg = cfg.model + vis_cfg = cfg.visualization + _VIS_CFG = vis_cfg # expose to other visualization modules + + width = int(model_cfg.width) + height = int(model_cfg.height) + cell_px = int(vis_cfg.cell_size) + draw_borders = bool(vis_cfg.draw_borders) + + def portrayal(agent): + # We only draw ColorCell objects (grid contains one per cell) + if not isinstance(agent, ColorCell): + return None + + color_name = COLORS[agent.color] \ + if 0 <= agent.color < len(COLORS) else "Black" + p = { + "Shape": "rect", + "w": 1, + "h": 1, + "Filled": "true", + "Layer": 0, + "Color": color_name, + # Hover fields: + "Position": f"{agent.position}", + "Color - text": color_name, + } + + # Mark area borders (except global area) as circles + if draw_borders and agent.is_border_cell: + p["Shape"] = "circle" + p["r"] = 0.9 + if color_name == "LightGray": + p["Color"] = "Gainsboro" + + # Show agent count in the cell + if agent.num_agents_in_cell > 0: + p["text"] = str(agent.num_agents_in_cell) + p["text_color"] = "Black" + + # Add area info (tooltips) + for a in agent.areas: + aid = a.unique_id if a.unique_id != -1 else "global" + p[f"Area {aid}"] = \ + f"{a.num_agents} agents, color dist: {a.color_distribution}" + + # Add agent info (tooltips) + for voter in agent.agents: + p[f"Agent {voter.unique_id}"] = \ + f"personality: {voter.personality}, assets: {voter.assets}" + + return p + + return CanvasGrid(portrayal, width, height, width * cell_px, height * cell_px) + + +def make_charts(cfg: AppConfig) -> list: + """ + Build the list of chart/extra visualization elements. + """ + model_cfg = cfg.model.model_dump() + num_colors = int(model_cfg["num_colors"]) + + color_distribution_chart = ChartModule( + [{"Label": f"Color {i}", + "Color": ("LightGrey" if COLORS[i] == "LightGray" else COLORS[i])} + for i in range(num_colors)], + data_collector_name="datacollector", + ) + + wealth_chart = ChartModule( + [{"Label": "Collective assets", "Color": "Black"}], + data_collector_name="datacollector", + ) + + voter_turnout = ChartModule( + [ + {"Label": "Voter turnout globally (in percent)", "Color": "Black"}, + {"Label": "Gini Index (0-100)", "Color": "Red"}, + ], + data_collector_name="datacollector", + ) + + # Advanced matplotlib-based elements + #try: + from src.viz.visualisation_elements import ( + PersonalityDistribution, + AreaStats, + VoterTurnoutElement, + AreaPersonalityDists, + ) + extras = [ + PersonalityDistribution(), + AreaStats(), + VoterTurnoutElement(), + AreaPersonalityDists(), + ] + #except Exception: + # extras = [] + + return [color_distribution_chart, wealth_chart, voter_turnout, *extras] diff --git a/democracy_sim/visualisation_elements.py b/src/viz/visualisation_elements.py similarity index 71% rename from democracy_sim/visualisation_elements.py rename to src/viz/visualisation_elements.py index bbc4bdf..ea5b644 100644 --- a/democracy_sim/visualisation_elements.py +++ b/src/viz/visualisation_elements.py @@ -1,13 +1,15 @@ import matplotlib.pyplot as plt -from typing import TYPE_CHECKING, cast from mesa.visualization import TextElement import matplotlib.patches as patches -from model_setup import _COLORS +from src.viz.factory import COLORS, get_vis_cfg import base64 import math import io -_COLORS[0] = "LightGray" +# Visualization config (is set by make_canvas before these are instantiated) +vis_cfg = get_vis_cfg() +show_area_stats = bool(vis_cfg.show_area_stats) + def save_plot_to_base64(fig): buf = io.BytesIO() @@ -21,50 +23,47 @@ def save_plot_to_base64(fig): class AreaStats(TextElement): def render(self, model): - # Only render if show_area_stats is enabled step = model.scheduler.steps - if not model.show_area_stats or step == 0: + if not show_area_stats or step == 0: return "" - # Fetch data from the datacollector data = model.datacollector.get_agent_vars_dataframe() color_distribution = data['ColorDistribution'].dropna() dist_to_reality = data['DistToReality'].dropna() election_results = data['ElectionResults'].dropna() - # Extract unique area IDs (excluding the global area) area_ids = color_distribution.index.get_level_values(1).unique()[1:] + if len(color_distribution) == 0 or len(area_ids) == 0: + return "" + num_colors = len(color_distribution.iloc[0]) num_areas = len(area_ids) - - # Create subplots with two columns (two plots per area). fig, axes = plt.subplots(nrows=num_areas, ncols=2, figsize=(8, 4 * num_areas), sharex=True) - for area_id in area_ids: - row = area_id - # Left plot: distance to reality value and color distribution - ax1 = axes[row, 0] + # Handle case of single area (axes shape) + if num_areas == 1: + axes = [axes] + + for i, area_id in enumerate(area_ids): + row = i + ax1 = axes[row][0] area_data = color_distribution.xs(area_id, level=1) a_data = dist_to_reality.xs(area_id, level=1) ax1.plot(a_data.index, a_data.values, color='Black', linestyle='--') for color_idx in range(num_colors): - color_data = area_data.apply(lambda x: x[color_idx]) - ax1.plot(color_data.index, color_data.values, - color=_COLORS[color_idx]) - ax1.set_title(f'Area {area_id} \n' - f'--- deviation from voted distribution') + cdata = area_data.apply(lambda x: x[color_idx]) + ax1.plot(cdata.index, cdata.values, color=COLORS[color_idx]) + ax1.set_title(f'Area {area_id} \n--- deviation from voted distribution') ax1.set_xlabel('Step') ax1.set_ylabel('Color Distribution') - # Right plot: election result - ax2 = axes[row, 1] + ax2 = axes[row][1] area_data = election_results.xs(area_id, level=1) for color_id in range(num_colors): - color_data = area_data.apply(lambda x: list(x).index( - color_id) if color_id in x else None) - ax2.plot(color_data.index, color_data.values, marker='o', - label=f'Color {color_id}', color=_COLORS[color_id], + cdata = area_data.apply(lambda x: list(x).index(color_id) if color_id in x else None) + ax2.plot(cdata.index, cdata.values, marker='o', + label=f'Color {color_id}', color=COLORS[color_id], linewidth=0.2) ax2.set_title(f'Area {area_id} \n') ax2.set_xlabel('Step') @@ -72,37 +71,29 @@ def render(self, model): ax2.invert_yaxis() plt.tight_layout() - combined_plot = save_plot_to_base64(fig) - - return combined_plot + return save_plot_to_base64(fig) class PersonalityDistribution(TextElement): - def __init__(self): super().__init__() - self.personality_distribution = None self.pers_dist_plot = None def create_once(self, model): - if TYPE_CHECKING: - model = cast('ParticipationModel', model) - # Fetch data dists = model.personality_distribution personalities = model.personalities num_personalities = personalities.shape[0] num_agents = model.num_agents - colors = _COLORS[:model.num_colors] + colors = COLORS[:model.num_colors] num_colors = len(personalities[0]) fig, ax = plt.subplots(figsize=(6, 4)) - heights = dists * num_agents + heights = dists bars = ax.bar(range(num_personalities), heights, width=0.6) for bar, personality in zip(bars, personalities): height = bar.get_height() width = bar.get_width() - for i, color_idx in enumerate(personality): rect_width = width / num_colors coords = (bar.get_x() + i * rect_width, 0) @@ -111,14 +102,12 @@ def create_once(self, model): ax.add_patch(rect) ax.set_xlabel('"Personality" ID') - ax.set_ylabel('Number of Agents') + ax.set_ylabel(f'Percentage of the {num_agents} Agents') ax.set_title('Global distribution of personalities among agents') - plt.tight_layout() self.pers_dist_plot = save_plot_to_base64(fig) def render(self, model): - # Only create a new plot at the start of a simulation if model.scheduler.steps == 0: self.create_once(model) return self.pers_dist_plot @@ -126,20 +115,16 @@ def render(self, model): class VoterTurnoutElement(TextElement): def render(self, model): - # Only render if show_area_stats is enabled step = model.scheduler.steps - if not model.show_area_stats or step == 0: + if not show_area_stats or step == 0: return "" - # Fetch data from the datacollector data = model.datacollector.get_agent_vars_dataframe() voter_turnout = data['VoterTurnout'].dropna() + if len(voter_turnout) == 0: + return "" - # Extract unique area IDs area_ids = voter_turnout.index.get_level_values(1).unique() - - # Create a single plot fig, ax = plt.subplots(figsize=(8, 6)) - for i, area_id in enumerate(area_ids): area_data = voter_turnout.xs(area_id, level=1) if i < 10: @@ -154,79 +139,72 @@ def render(self, model): ax.set_xlabel('Step') ax.set_ylabel('Voter Turnout (%)') ax.legend() - return save_plot_to_base64(fig) class MatplotlibElement(TextElement): def render(self, model): - # Only render if show_area_stats is enabled step = model.scheduler.steps - if not model.show_area_stats or step == 0: + if not show_area_stats or step == 0: return "" - # Fetch data from the datacollector data = model.datacollector.get_model_vars_dataframe() - collective_assets = data["Collective assets"] - - # Create a plot + collective_assets = data.get("Collective assets") + if collective_assets is None: + return "" fig, ax = plt.subplots() ax.plot(collective_assets, label="Collective assets") ax.set_title("Collective Assets Over Time") ax.set_xlabel("Time") ax.set_ylabel("Collective Assets") ax.legend() - return save_plot_to_base64(fig) + class StepsTextElement(TextElement): def render(self, model): step = model.scheduler.steps - # TODO clean up first_agents = [str(a) for a in model.voting_agents[:5]] - text = (f"Step: {step} | cells: {len(model.color_cells)} | " + return (f"Step: {step} | cells: {len(model.color_cells)} | " f"areas: {len(model.areas)} | First 5 voters of " f"{len(model.voting_agents)}: {first_agents}") - return text class AreaPersonalityDists(TextElement): - def __init__(self): super().__init__() - self.personality_distributions = None self.areas_pers_dist_plot = None def create_once(self, model): - if TYPE_CHECKING: - model = cast('ParticipationModel', model) - - colors = _COLORS[:model.num_colors] + colors = COLORS[:model.num_colors] personalities = model.personalities num_colors = len(personalities[0]) num_personalities = personalities.shape[0] - # Create subplots within a single figure num_areas = len(model.areas) + if num_areas == 0: + self.areas_pers_dist_plot = "" + return + num_cols = math.ceil(math.sqrt(num_areas)) num_rows = math.ceil(num_areas / num_cols) fig, axes = plt.subplots(nrows=num_rows, ncols=num_cols, - figsize=(8, 8), sharex=True) - for ax, area in zip(axes.flatten(), model.areas): - # Fetch data + figsize=(8, num_areas), sharex=True) + axes_flat = axes.flatten() if hasattr(axes, "flatten") else [axes] + for ax, area in zip(axes_flat, model.areas): p_dist = area.personality_distribution num_agents = area.num_agents - # Subplot heights = [int(val * num_agents) for val in p_dist] bars = ax.bar(range(num_personalities), heights, color='skyblue') + max_height = max(heights) if heights else 1 + p_top_hight = max_height * 0.02 for bar, personality in zip(bars, personalities): height = bar.get_height() width = bar.get_width() - for i, color_idx in enumerate(personality): rect_width = width / num_colors coords = (bar.get_x() + i * rect_width, height) - rect = patches.Rectangle(coords, rect_width, 2, + rect = patches.Rectangle(coords, rect_width, p_top_hight, color=colors[color_idx]) ax.add_patch(rect) @@ -238,7 +216,6 @@ def create_once(self, model): self.areas_pers_dist_plot = save_plot_to_base64(fig) def render(self, model): - # Only create a new plot at the start of a simulation if model.scheduler.steps == 0: self.create_once(model) return self.areas_pers_dist_plot diff --git a/tests/LICENSE b/tests/LICENSE deleted file mode 100644 index 116dda9..0000000 --- a/tests/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2023 Core Mesa Team and contributors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..898b2a5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# python +# tests/__init__.py \ No newline at end of file diff --git a/tests/factory.py b/tests/factory.py index 3379e66..b444326 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -1,31 +1,12 @@ -from democracy_sim.participation_model import ParticipationModel +from src.models.participation_model import ParticipationModel +from pathlib import Path +import yaml +DEFAULT_CONFIG = Path("configs/default.yaml") def create_default_model(**overrides): - """Create a ParticipationModel instance, with optional parameter overrides.""" - params = { - "height": 100, - "width": 80, - "num_agents": 800, - "num_colors": 3, - "num_personalities": 4, - "mu": 0.05, - "election_impact_on_mutation": 1.8, - "common_assets": 40000, - "known_cells": 10, - "num_areas": 16, - "av_area_height": 25, - "av_area_width": 20, - "area_size_variance": 0.0, - "patch_power": 1.0, - "color_patches_steps": 3, - "draw_borders": True, - "heterogeneity": 0.3, - "rule_idx": 1, - "distance_idx": 1, - "election_costs": 1, - "max_reward": 50, - "show_area_stats": False - } + with open(DEFAULT_CONFIG, "r") as f: + config = yaml.safe_load(f) + params = config["model"] params.update(overrides) return ParticipationModel(**params) diff --git a/tests/read_requirements.py b/tests/read_requirements.py deleted file mode 100644 index 83ccfd9..0000000 --- a/tests/read_requirements.py +++ /dev/null @@ -1,8 +0,0 @@ -import toml - -# This file reads the pyproject.toml and prints out the -# dependencies and dev dependencies. -# It is located in tests/ folder so as not to pollute the root repo. -c = toml.load("pyproject.toml") -print("\n".join(c["project"]["dependencies"])) -print("\n".join(c["project"]["optional-dependencies"]["dev"])) diff --git a/tests/test_agent.py b/tests/test_agent.py deleted file mode 100644 index 0cd2111..0000000 --- a/tests/test_agent.py +++ /dev/null @@ -1,284 +0,0 @@ -import pickle - -import pytest - -from mesa.agent import Agent, AgentSet -from mesa.model import Model - - -class TestAgent(Agent): - def get_unique_identifier(self): - return self.unique_id - - -class TestAgentDo(Agent): - def __init__( - self, - unique_id, - model, - ): - super().__init__(unique_id, model) - self.agent_set = None - - def get_unique_identifier(self): - return self.unique_id - - def do_add(self): - agent = TestAgentDo(self.model.next_id(), self.model) - self.agent_set.add(agent) - - def do_remove(self): - self.agent_set.remove(self) - - -def test_agent_removal(): - model = Model() - agent = TestAgent(model.next_id(), model) - # Check if the agent is added - assert agent in model.agents - - agent.remove() - # Check if the agent is removed - assert agent not in model.agents - - -def test_agentset(): - # create agentset - model = Model() - agents = [TestAgent(model.next_id(), model) for _ in range(10)] - - agentset = AgentSet(agents, model) - - assert agents[0] in agentset - assert len(agentset) == len(agents) - assert all(a1 == a2 for a1, a2 in zip(agentset[0:5], agents[0:5])) - - for a1, a2 in zip(agentset, agents): - assert a1 == a2 - - def test_function(agent): - return agent.unique_id > 5 - - assert len(agentset.select(test_function)) == 5 - assert len(agentset.select(test_function, n=2)) == 2 - assert len(agentset.select(test_function, inplace=True)) == 5 - assert agentset.select(inplace=True) == agentset - assert all(a1 == a2 for a1, a2 in zip(agentset.select(), agentset)) - assert all(a1 == a2 for a1, a2 in zip(agentset.select(n=5), agentset[:5])) - - assert len(agentset.shuffle(inplace=False).select(n=5)) == 5 - - def test_function(agent): - return agent.unique_id - - assert all( - a1 == a2 - for a1, a2 in zip(agentset.sort(test_function, ascending=False), agentset[::-1]) - ) - assert all( - a1 == a2 - for a1, a2 in zip(agentset.sort("unique_id", ascending=False), agentset[::-1]) - ) - - assert all( - a1 == a2.unique_id for a1, a2 in zip(agentset.get("unique_id"), agentset) - ) - assert all( - a1 == a2.unique_id - for a1, a2 in zip( - agentset.do("get_unique_identifier", return_results=True), agentset - ) - ) - assert agentset == agentset.do("get_unique_identifier") - - agentset.discard(agents[0]) - assert agents[0] not in agentset - agentset.discard(agents[0]) # check if no error is raised on discard - - with pytest.raises(KeyError): - agentset.remove(agents[0]) - - agentset.add(agents[0]) - assert agents[0] in agentset - - # because AgentSet uses weakrefs, we need hard refs as well.... - other_agents, another_set = pickle.loads( # noqa: S301 - pickle.dumps([agents, AgentSet(agents, model)]) - ) - assert all( - a1.unique_id == a2.unique_id for a1, a2 in zip(another_set, other_agents) - ) - assert len(another_set) == len(other_agents) - - -def test_agentset_initialization(): - model = Model() - empty_agentset = AgentSet([], model) - assert len(empty_agentset) == 0 - - agents = [TestAgent(model.next_id(), model) for _ in range(10)] - agentset = AgentSet(agents, model) - assert len(agentset) == 10 - - -def test_agentset_serialization(): - model = Model() - agents = [TestAgent(model.next_id(), model) for _ in range(5)] - agentset = AgentSet(agents, model) - - serialized = pickle.dumps(agentset) - deserialized = pickle.loads(serialized) # noqa: S301 - - original_ids = [agent.unique_id for agent in agents] - deserialized_ids = [agent.unique_id for agent in deserialized] - - assert deserialized_ids == original_ids - - -def test_agent_membership(): - model = Model() - agents = [TestAgent(model.next_id(), model) for _ in range(5)] - agentset = AgentSet(agents, model) - - assert agents[0] in agentset - assert TestAgent(model.next_id(), model) not in agentset - - -def test_agent_add_remove_discard(): - model = Model() - agent = TestAgent(model.next_id(), model) - agentset = AgentSet([], model) - - agentset.add(agent) - assert agent in agentset - - agentset.remove(agent) - assert agent not in agentset - - agentset.add(agent) - agentset.discard(agent) - assert agent not in agentset - - with pytest.raises(KeyError): - agentset.remove(agent) - - -def test_agentset_get_item(): - model = Model() - agents = [TestAgent(model.next_id(), model) for _ in range(10)] - agentset = AgentSet(agents, model) - - assert agentset[0] == agents[0] - assert agentset[-1] == agents[-1] - assert agentset[1:3] == agents[1:3] - - with pytest.raises(IndexError): - _ = agentset[20] - - -def test_agentset_do_method(): - model = Model() - agents = [TestAgent(model.next_id(), model) for _ in range(10)] - agentset = AgentSet(agents, model) - - with pytest.raises(AttributeError): - agentset.do("non_existing_method") - - # tests for addition and removal in do - # do iterates, so no error should be raised to change size while iterating - # related to issue #1595 - - # setup - n = 10 - model = Model() - agents = [TestAgentDo(model.next_id(), model) for _ in range(n)] - agentset = AgentSet(agents, model) - for agent in agents: - agent.agent_set = agentset - - agentset.do("do_add") - assert len(agentset) == 2 * n - - # setup - model = Model() - agents = [TestAgentDo(model.next_id(), model) for _ in range(10)] - agentset = AgentSet(agents, model) - for agent in agents: - agent.agent_set = agentset - - agentset.do("do_remove") - assert len(agentset) == 0 - - -def test_agentset_get_attribute(): - model = Model() - agents = [TestAgent(model.next_id(), model) for _ in range(10)] - agentset = AgentSet(agents, model) - - unique_ids = agentset.get("unique_id") - assert unique_ids == [agent.unique_id for agent in agents] - - with pytest.raises(AttributeError): - agentset.get("non_existing_attribute") - - model = Model() - agents = [] - for i in range(10): - agent = TestAgent(model.next_id(), model) - agent.i = i**2 - agents.append(agent) - agentset = AgentSet(agents, model) - - values = agentset.get(["unique_id", "i"]) - - for value, agent in zip(values, agents): - ( - unique_id, - i, - ) = value - assert agent.unique_id == unique_id - assert agent.i == i - - -class OtherAgentType(Agent): - def get_unique_identifier(self): - return self.unique_id - - -def test_agentset_select_by_type(): - model = Model() - # Create a mix of agents of two different types - test_agents = [TestAgent(model.next_id(), model) for _ in range(4)] - other_agents = [OtherAgentType(model.next_id(), model) for _ in range(6)] - - # Combine the two types of agents - mixed_agents = test_agents + other_agents - agentset = AgentSet(mixed_agents, model) - - # Test selection by type - selected_test_agents = agentset.select(agent_type=TestAgent) - assert len(selected_test_agents) == len(test_agents) - assert all(isinstance(agent, TestAgent) for agent in selected_test_agents) - assert len(selected_test_agents) == 4 - - selected_other_agents = agentset.select(agent_type=OtherAgentType) - assert len(selected_other_agents) == len(other_agents) - assert all(isinstance(agent, OtherAgentType) for agent in selected_other_agents) - assert len(selected_other_agents) == 6 - - # Test with no type specified (should select all agents) - all_agents = agentset.select() - assert len(all_agents) == len(mixed_agents) - - -def test_agentset_shuffle(): - model = Model() - test_agents = [TestAgent(model.next_id(), model) for _ in range(12)] - - agentset = AgentSet(test_agents, model=model) - agentset = agentset.shuffle() - assert not all(a1 == a2 for a1, a2 in zip(test_agents, agentset)) - - agentset = AgentSet(test_agents, model=model) - agentset.shuffle(inplace=True) - assert not all(a1 == a2 for a1, a2 in zip(test_agents, agentset)) diff --git a/tests/test_approval_voting.py b/tests/test_approval_voting.py index 60ec936..1d02eaf 100644 --- a/tests/test_approval_voting.py +++ b/tests/test_approval_voting.py @@ -1,4 +1,4 @@ -from democracy_sim.social_welfare_functions import approval_voting +from src.utils.social_welfare_functions import approval_voting from tests.test_majority_rule import simple, paradoxical import numpy as np diff --git a/tests/test_batch_run.py b/tests/test_batch_run.py deleted file mode 100644 index f5ddb0a..0000000 --- a/tests/test_batch_run.py +++ /dev/null @@ -1,200 +0,0 @@ -import mesa -from mesa.agent import Agent -from mesa.batchrunner import _make_model_kwargs -from mesa.datacollection import DataCollector -from mesa.model import Model -from mesa.time import BaseScheduler - - -def test_make_model_kwargs(): - assert _make_model_kwargs({"a": 3, "b": 5}) == [{"a": 3, "b": 5}] - assert _make_model_kwargs({"a": 3, "b": range(3)}) == [ - {"a": 3, "b": 0}, - {"a": 3, "b": 1}, - {"a": 3, "b": 2}, - ] - assert _make_model_kwargs({"a": range(2), "b": range(2)}) == [ - {"a": 0, "b": 0}, - {"a": 0, "b": 1}, - {"a": 1, "b": 0}, - {"a": 1, "b": 1}, - ] - # If the value is a single string, do not iterate over it. - assert _make_model_kwargs({"a": "value"}) == [{"a": "value"}] - - -class MockAgent(Agent): - """ - Minimalistic agent implementation for testing purposes - """ - - def __init__(self, unique_id, model, val): - super().__init__(unique_id, model) - self.unique_id = unique_id - self.val = val - self.local = 0 - - def step(self): - self.val += 1 - self.local += 0.25 - - -class MockModel(Model): - """ - Minimalistic model for testing purposes - """ - - def __init__( - self, - variable_model_param=None, - variable_agent_param=None, - fixed_model_param=None, - schedule=None, - enable_agent_reporters=True, - n_agents=3, - **kwargs, - ): - super().__init__() - self.schedule = BaseScheduler(self) if schedule is None else schedule - self.variable_model_param = variable_model_param - self.variable_agent_param = variable_agent_param - self.fixed_model_param = fixed_model_param - self.n_agents = n_agents - if enable_agent_reporters: - agent_reporters = {"agent_id": "unique_id", "agent_local": "local"} - else: - agent_reporters = None - self.datacollector = DataCollector( - model_reporters={"reported_model_param": self.get_local_model_param}, - agent_reporters=agent_reporters, - ) - self.running = True - self.init_agents() - - def init_agents(self): - if self.variable_agent_param is None: - agent_val = 1 - else: - agent_val = self.variable_agent_param - for i in range(self.n_agents): - self.schedule.add(MockAgent(i, self, agent_val)) - - def get_local_model_param(self): - return 42 - - def step(self): - self.datacollector.collect(self) - self.schedule.step() - - -def test_batch_run(): - result = mesa.batch_run(MockModel, {}, number_processes=2) - assert result == [ - { - "RunId": 0, - "iteration": 0, - "Step": 1000, - "reported_model_param": 42, - "AgentID": 0, - "agent_id": 0, - "agent_local": 250.0, - }, - { - "RunId": 0, - "iteration": 0, - "Step": 1000, - "reported_model_param": 42, - "AgentID": 1, - "agent_id": 1, - "agent_local": 250.0, - }, - { - "RunId": 0, - "iteration": 0, - "Step": 1000, - "reported_model_param": 42, - "AgentID": 2, - "agent_id": 2, - "agent_local": 250.0, - }, - ] - - -def test_batch_run_with_params(): - mesa.batch_run( - MockModel, - { - "variable_model_params": range(3), - "variable_agent_params": ["H", "E", "Y"], - }, - number_processes=2, - ) - - -def test_batch_run_no_agent_reporters(): - result = mesa.batch_run( - MockModel, {"enable_agent_reporters": False}, number_processes=2 - ) - print(result) - assert result == [ - { - "RunId": 0, - "iteration": 0, - "Step": 1000, - "enable_agent_reporters": False, - "reported_model_param": 42, - } - ] - - -def test_batch_run_single_core(): - mesa.batch_run(MockModel, {}, number_processes=1, iterations=6) - - -def test_batch_run_unhashable_param(): - result = mesa.batch_run( - MockModel, - { - "n_agents": 2, - "variable_model_params": [{"key": "value"}], - }, - iterations=2, - ) - template = { - "Step": 1000, - "reported_model_param": 42, - "agent_local": 250.0, - "n_agents": 2, - "variable_model_params": {"key": "value"}, - } - - assert result == [ - { - "RunId": 0, - "iteration": 0, - "AgentID": 0, - "agent_id": 0, - **template, - }, - { - "RunId": 0, - "iteration": 0, - "AgentID": 1, - "agent_id": 1, - **template, - }, - { - "RunId": 1, - "iteration": 1, - "AgentID": 0, - "agent_id": 0, - **template, - }, - { - "RunId": 1, - "iteration": 1, - "AgentID": 1, - "agent_id": 1, - **template, - }, - ] diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py deleted file mode 100644 index 54116d8..0000000 --- a/tests/test_cell_space.py +++ /dev/null @@ -1,463 +0,0 @@ -import random -import pytest -from mesa import Model -from mesa.experimental.cell_space import ( - Cell, - CellAgent, - CellCollection, - HexGrid, - Network, - OrthogonalMooreGrid, - OrthogonalVonNeumannGrid, -) - - -def test_orthogonal_grid_neumann(): - width = 10 - height = 10 - grid = OrthogonalVonNeumannGrid((width, height), torus=False, capacity=None) - - assert len(grid._cells) == width * height - - # von neumann neighborhood, torus false, top left corner - assert len(grid._cells[(0, 0)]._connections) == 2 - for connection in grid._cells[(0, 0)]._connections: - assert connection.coordinate in {(0, 1), (1, 0)} - - # von neumann neighborhood, torus false, top right corner - for connection in grid._cells[(0, width - 1)]._connections: - assert connection.coordinate in {(0, width - 2), (1, width - 1)} - - # von neumann neighborhood, torus false, bottom left corner - for connection in grid._cells[(height - 1, 0)]._connections: - assert connection.coordinate in {(height - 1, 1), (height - 2, 0)} - - # von neumann neighborhood, torus false, bottom right corner - for connection in grid._cells[(height - 1, width - 1)]._connections: - assert connection.coordinate in { - (height - 1, width - 2), - (height - 2, width - 1), - } - - # von neumann neighborhood middle of grid - assert len(grid._cells[(5, 5)]._connections) == 4 - for connection in grid._cells[(5, 5)]._connections: - assert connection.coordinate in {(4, 5), (5, 4), (5, 6), (6, 5)} - - # von neumann neighborhood, torus True, top corner - grid = OrthogonalVonNeumannGrid((width, height), torus=True, capacity=None) - assert len(grid._cells[(0, 0)]._connections) == 4 - for connection in grid._cells[(0, 0)]._connections: - assert connection.coordinate in {(0, 1), (1, 0), (0, 9), (9, 0)} - - # von neumann neighborhood, torus True, top right corner - for connection in grid._cells[(0, width - 1)]._connections: - assert connection.coordinate in {(0, 8), (0, 0), (1, 9), (9, 9)} - - # von neumann neighborhood, torus True, bottom left corner - for connection in grid._cells[(9, 0)]._connections: - assert connection.coordinate in {(9, 1), (9, 9), (0, 0), (8, 0)} - - # von neumann neighborhood, torus True, bottom right corner - for connection in grid._cells[(9, 9)]._connections: - assert connection.coordinate in {(9, 0), (9, 8), (8, 9), (0, 9)} - - -def test_orthogonal_grid_neumann_3d(): - width = 10 - height = 10 - depth = 10 - grid = OrthogonalVonNeumannGrid((width, height, depth), torus=False, capacity=None) - - assert len(grid._cells) == width * height * depth - - # von neumann neighborhood, torus false, top left corner - assert len(grid._cells[(0, 0, 0)]._connections) == 3 - for connection in grid._cells[(0, 0, 0)]._connections: - assert connection.coordinate in {(0, 0, 1), (0, 1, 0), (1, 0, 0)} - - # von neumann neighborhood, torus false, top right corner - for connection in grid._cells[(0, width - 1, 0)]._connections: - assert connection.coordinate in { - (0, width - 1, 1), - (0, width - 2, 0), - (1, width - 1, 0), - } - - # von neumann neighborhood, torus false, bottom left corner - for connection in grid._cells[(height - 1, 0, 0)]._connections: - assert connection.coordinate in { - (height - 1, 0, 1), - (height - 1, 1, 0), - (height - 2, 0, 0), - } - - # von neumann neighborhood, torus false, bottom right corner - for connection in grid._cells[(height - 1, width - 1, 0)]._connections: - assert connection.coordinate in { - (height - 1, width - 1, 1), - (height - 1, width - 2, 0), - (height - 2, width - 1, 0), - } - - # von neumann neighborhood middle of grid - assert len(grid._cells[(5, 5, 5)]._connections) == 6 - for connection in grid._cells[(5, 5, 5)]._connections: - assert connection.coordinate in { - (4, 5, 5), - (5, 4, 5), - (5, 5, 4), - (5, 5, 6), - (5, 6, 5), - (6, 5, 5), - } - - # von neumann neighborhood, torus True, top corner - grid = OrthogonalVonNeumannGrid((width, height, depth), torus=True, capacity=None) - assert len(grid._cells[(0, 0, 0)]._connections) == 6 - for connection in grid._cells[(0, 0, 0)]._connections: - assert connection.coordinate in { - (0, 0, 1), - (0, 1, 0), - (1, 0, 0), - (0, 0, 9), - (0, 9, 0), - (9, 0, 0), - } - - -def test_orthogonal_grid_moore(): - width = 10 - height = 10 - - # Moore neighborhood, torus false, top corner - grid = OrthogonalMooreGrid((width, height), torus=False, capacity=None) - assert len(grid._cells[(0, 0)]._connections) == 3 - for connection in grid._cells[(0, 0)]._connections: - assert connection.coordinate in {(0, 1), (1, 0), (1, 1)} - - # Moore neighborhood middle of grid - assert len(grid._cells[(5, 5)]._connections) == 8 - for connection in grid._cells[(5, 5)]._connections: - # fmt: off - assert connection.coordinate in {(4, 4), (4, 5), (4, 6), - (5, 4), (5, 6), - (6, 4), (6, 5), (6, 6)} - # fmt: on - - # Moore neighborhood, torus True, top corner - grid = OrthogonalMooreGrid([10, 10], torus=True, capacity=None) - assert len(grid._cells[(0, 0)]._connections) == 8 - for connection in grid._cells[(0, 0)]._connections: - # fmt: off - assert connection.coordinate in {(9, 9), (9, 0), (9, 1), - (0, 9), (0, 1), - (1, 9), (1, 0), (1, 1)} - # fmt: on - - -def test_orthogonal_grid_moore_3d(): - width = 10 - height = 10 - depth = 10 - - # Moore neighborhood, torus false, top corner - grid = OrthogonalMooreGrid((width, height, depth), torus=False, capacity=None) - assert len(grid._cells[(0, 0, 0)]._connections) == 7 - for connection in grid._cells[(0, 0, 0)]._connections: - assert connection.coordinate in { - (0, 0, 1), - (0, 1, 0), - (0, 1, 1), - (1, 0, 0), - (1, 0, 1), - (1, 1, 0), - (1, 1, 1), - } - - # Moore neighborhood middle of grid - assert len(grid._cells[(5, 5, 5)]._connections) == 26 - for connection in grid._cells[(5, 5, 5)]._connections: - # fmt: off - assert connection.coordinate in {(4, 4, 4), (4, 4, 5), (4, 4, 6), (4, 5, 4), (4, 5, 5), (4, 5, 6), (4, 6, 4), (4, 6, 5), (4, 6, 6), - (5, 4, 4), (5, 4, 5), (5, 4, 6), (5, 5, 4), (5, 5, 6), (5, 6, 4), (5, 6, 5), (5, 6, 6), - (6, 4, 4), (6, 4, 5), (6, 4, 6), (6, 5, 4), (6, 5, 5), (6, 5, 6), (6, 6, 4), (6, 6, 5), (6, 6, 6)} - # fmt: on - - # Moore neighborhood, torus True, top corner - grid = OrthogonalMooreGrid((width, height, depth), torus=True, capacity=None) - assert len(grid._cells[(0, 0, 0)]._connections) == 26 - for connection in grid._cells[(0, 0, 0)]._connections: - # fmt: off - assert connection.coordinate in {(9, 9, 9), (9, 9, 0), (9, 9, 1), (9, 0, 9), (9, 0, 0), (9, 0, 1), (9, 1, 9), (9, 1, 0), (9, 1, 1), - (0, 9, 9), (0, 9, 0), (0, 9, 1), (0, 0, 9), (0, 0, 1), (0, 1, 9), (0, 1, 0), (0, 1, 1), - (1, 9, 9), (1, 9, 0), (1, 9, 1), (1, 0, 9), (1, 0, 0), (1, 0, 1), (1, 1, 9), (1, 1, 0), (1, 1, 1)} - # fmt: on - - -def test_orthogonal_grid_moore_4d(): - width = 10 - height = 10 - depth = 10 - time = 10 - - # Moore neighborhood, torus false, top corner - grid = OrthogonalMooreGrid((width, height, depth, time), torus=False, capacity=None) - assert len(grid._cells[(0, 0, 0, 0)]._connections) == 15 - for connection in grid._cells[(0, 0, 0, 0)]._connections: - assert connection.coordinate in { - (0, 0, 0, 1), - (0, 0, 1, 0), - (0, 0, 1, 1), - (0, 1, 0, 0), - (0, 1, 0, 1), - (0, 1, 1, 0), - (0, 1, 1, 1), - (1, 0, 0, 0), - (1, 0, 0, 1), - (1, 0, 1, 0), - (1, 0, 1, 1), - (1, 1, 0, 0), - (1, 1, 0, 1), - (1, 1, 1, 0), - (1, 1, 1, 1), - } - - # Moore neighborhood middle of grid - assert len(grid._cells[(5, 5, 5, 5)]._connections) == 80 - for connection in grid._cells[(5, 5, 5, 5)]._connections: - # fmt: off - assert connection.coordinate in {(4, 4, 4, 4), (4, 4, 4, 5), (4, 4, 4, 6), (4, 4, 5, 4), (4, 4, 5, 5), (4, 4, 5, 6), (4, 4, 6, 4), (4, 4, 6, 5), (4, 4, 6, 6), - (4, 5, 4, 4), (4, 5, 4, 5), (4, 5, 4, 6), (4, 5, 5, 4), (4, 5, 5, 5), (4, 5, 5, 6), (4, 5, 6, 4), (4, 5, 6, 5), (4, 5, 6, 6), - (4, 6, 4, 4), (4, 6, 4, 5), (4, 6, 4, 6), (4, 6, 5, 4), (4, 6, 5, 5), (4, 6, 5, 6), (4, 6, 6, 4), (4, 6, 6, 5), (4, 6, 6, 6), - (5, 4, 4, 4), (5, 4, 4, 5), (5, 4, 4, 6), (5, 4, 5, 4), (5, 4, 5, 5), (5, 4, 5, 6), (5, 4, 6, 4), (5, 4, 6, 5), (5, 4, 6, 6), - (5, 5, 4, 4), (5, 5, 4, 5), (5, 5, 4, 6), (5, 5, 5, 4), (5, 5, 5, 6), (5, 5, 6, 4), (5, 5, 6, 5), (5, 5, 6, 6), - (5, 6, 4, 4), (5, 6, 4, 5), (5, 6, 4, 6), (5, 6, 5, 4), (5, 6, 5, 5), (5, 6, 5, 6), (5, 6, 6, 4), (5, 6, 6, 5), (5, 6, 6, 6), - (6, 4, 4, 4), (6, 4, 4, 5), (6, 4, 4, 6), (6, 4, 5, 4), (6, 4, 5, 5), (6, 4, 5, 6), (6, 4, 6, 4), (6, 4, 6, 5), (6, 4, 6, 6), - (6, 5, 4, 4), (6, 5, 4, 5), (6, 5, 4, 6), (6, 5, 5, 4), (6, 5, 5, 5), (6, 5, 5, 6), (6, 5, 6, 4), (6, 5, 6, 5), (6, 5, 6, 6), - (6, 6, 4, 4), (6, 6, 4, 5), (6, 6, 4, 6), (6, 6, 5, 4), (6, 6, 5, 5), (6, 6, 5, 6), (6, 6, 6, 4), (6, 6, 6, 5), (6, 6, 6, 6)} - # fmt: on - - -def test_orthogonal_grid_moore_1d(): - width = 10 - - # Moore neighborhood, torus false, left edge - grid = OrthogonalMooreGrid((width,), torus=False, capacity=None) - assert len(grid._cells[(0,)]._connections) == 1 - for connection in grid._cells[(0,)]._connections: - assert connection.coordinate in {(1,)} - - # Moore neighborhood middle of grid - assert len(grid._cells[(5,)]._connections) == 2 - for connection in grid._cells[(5,)]._connections: - assert connection.coordinate in {(4,), (6,)} - - # Moore neighborhood, torus True, left edge - grid = OrthogonalMooreGrid((width,), torus=True, capacity=None) - assert len(grid._cells[(0,)]._connections) == 2 - for connection in grid._cells[(0,)]._connections: - assert connection.coordinate in {(1,), (9,)} - - -def test_cell_neighborhood(): - # orthogonal grid - - ## von Neumann - width = 10 - height = 10 - grid = OrthogonalVonNeumannGrid((width, height), torus=False, capacity=None) - for radius, n in zip(range(1, 4), [2, 5, 9]): - neighborhood = grid._cells[(0, 0)].neighborhood(radius=radius) - assert len(neighborhood) == n - - ## Moore - width = 10 - height = 10 - grid = OrthogonalMooreGrid((width, height), torus=False, capacity=None) - for radius, n in zip(range(1, 4), [3, 8, 15]): - neighborhood = grid._cells[(0, 0)].neighborhood(radius=radius) - assert len(neighborhood) == n - - with pytest.raises(ValueError): - grid._cells[(0, 0)].neighborhood(radius=0) - - # hexgrid - width = 10 - height = 10 - grid = HexGrid((width, height), torus=False, capacity=None) - for radius, n in zip(range(1, 4), [2, 6, 11]): - neighborhood = grid._cells[(0, 0)].neighborhood(radius=radius) - assert len(neighborhood) == n - - width = 10 - height = 10 - grid = HexGrid((width, height), torus=False, capacity=None) - for radius, n in zip(range(1, 4), [5, 10, 17]): - neighborhood = grid._cells[(1, 0)].neighborhood(radius=radius) - assert len(neighborhood) == n - - # networkgrid - - -def test_hexgrid(): - width = 10 - height = 10 - - grid = HexGrid((width, height), torus=False) - assert len(grid._cells) == width * height - - # first row - assert len(grid._cells[(0, 0)]._connections) == 2 - for connection in grid._cells[(0, 0)]._connections: - assert connection.coordinate in {(0, 1), (1, 0)} - - # second row - assert len(grid._cells[(1, 0)]._connections) == 5 - for connection in grid._cells[(1, 0)]._connections: - # fmt: off - assert connection.coordinate in {(0, 0), (0, 1), - (1, 1), - (2, 0), (2, 1)} - - # middle odd row - assert len(grid._cells[(5, 5)]._connections) == 6 - for connection in grid._cells[(5, 5)]._connections: - # fmt: off - assert connection.coordinate in {(4, 5), (4, 6), - (5, 4), (5, 6), - (6, 5), (6, 6)} - - # fmt: on - - # middle even row - assert len(grid._cells[(4, 4)]._connections) == 6 - for connection in grid._cells[(4, 4)]._connections: - # fmt: off - assert connection.coordinate in {(3, 3), (3, 4), - (4, 3), (4, 5), - (5, 3), (5, 4)} - - # fmt: on - - grid = HexGrid((width, height), torus=True) - assert len(grid._cells) == width * height - - # first row - assert len(grid._cells[(0, 0)]._connections) == 6 - for connection in grid._cells[(0, 0)]._connections: - # fmt: off - assert connection.coordinate in {(9, 9), (9, 0), - (0, 9), (0, 1), - (1, 9), (1, 0)} - - # fmt: on - - -def test_networkgrid(): - import networkx as nx - - n = 10 - m = 20 - seed = 42 - G = nx.gnm_random_graph(n, m, seed=seed) # noqa: N806 - grid = Network(G) - - assert len(grid._cells) == n - - for i, cell in grid._cells.items(): - for connection in cell._connections: - assert connection.coordinate in G.neighbors(i) - - -def test_empties_space(): - import networkx as nx - - n = 10 - m = 20 - seed = 42 - G = nx.gnm_random_graph(n, m, seed=seed) # noqa: N806 - grid = Network(G) - - assert len(grid.empties) == n - - model = Model() - for i in range(8): - grid._cells[i].add_agent(CellAgent(i, model)) - - cell = grid.select_random_empty_cell() - assert cell.coordinate in {8, 9} - - -def test_cell(): - cell1 = Cell((1,), capacity=None, random=random.Random()) - cell2 = Cell((2,), capacity=None, random=random.Random()) - - # connect - cell1.connect(cell2) - assert cell2 in cell1._connections - - # disconnect - cell1.disconnect(cell2) - assert cell2 not in cell1._connections - - # remove cell not in connections - with pytest.raises(ValueError): - cell1.disconnect(cell2) - - # add_agent - model = Model() - agent = CellAgent(1, model) - - cell1.add_agent(agent) - assert agent in cell1.agents - - # remove_agent - cell1.remove_agent(agent) - assert agent not in cell1.agents - - with pytest.raises(ValueError): - cell1.remove_agent(agent) - - cell1 = Cell((1,), capacity=1, random=random.Random()) - cell1.add_agent(CellAgent(1, model)) - assert cell1.is_full - - with pytest.raises(Exception): - cell1.add_agent(CellAgent(2, model)) - - -def test_cell_collection(): - cell1 = Cell((1,), capacity=None, random=random.Random()) - - collection = CellCollection({cell1: cell1.agents}, random=random.Random()) - assert len(collection) == 1 - assert cell1 in collection - - rng = random.Random() - n = 10 - collection = CellCollection([Cell((i,), random=rng) for i in range(n)], random=rng) - assert len(collection) == n - - cell = collection.select_random_cell() - assert cell in collection - - cells = collection.cells - assert len(cells) == n - - agents = collection.agents - assert len(list(agents)) == 0 - - cells = collection.cells - model = Model() - cells[0].add_agent(CellAgent(1, model)) - cells[3].add_agent(CellAgent(2, model)) - cells[7].add_agent(CellAgent(3, model)) - agents = collection.agents - assert len(list(agents)) == 3 - - agent = collection.select_random_agent() - assert agent in set(collection.agents) - - agents = collection[cells[0]] - assert agents == cells[0].agents diff --git a/tests/test_color_by_dst.py b/tests/test_color_by_dst.py index ce80cc0..5b5bc65 100644 --- a/tests/test_color_by_dst.py +++ b/tests/test_color_by_dst.py @@ -1,6 +1,6 @@ import unittest import numpy as np -from democracy_sim.participation_model import ParticipationModel +from src.models.participation_model import ParticipationModel class TestColorByDst(unittest.TestCase): diff --git a/tests/test_create_personalities.py b/tests/test_create_personalities.py index 98a90cd..3bfa193 100644 --- a/tests/test_create_personalities.py +++ b/tests/test_create_personalities.py @@ -1,5 +1,5 @@ import unittest -import numpy as np +from math import factorial from itertools import permutations from tests.factory import create_default_model from unittest.mock import MagicMock @@ -48,7 +48,7 @@ def test_create_personalities_minimum_input(self): def test_create_personalities_full_permutation(self): """Test that generating the full set of permutations does return all.""" num_colors = self.model.num_colors - n_personalities = np.math.factorial(num_colors) + n_personalities = factorial(num_colors) personalities = self.model.create_personalities(n_personalities) expected_permutations = set(permutations(range(num_colors))) self.assertEqual(set(map(tuple, personalities)), expected_permutations) diff --git a/tests/test_datacollector.py b/tests/test_datacollector.py deleted file mode 100644 index bf74e37..0000000 --- a/tests/test_datacollector.py +++ /dev/null @@ -1,234 +0,0 @@ -""" -Test the DataCollector -""" - -import unittest - -from mesa import Agent, Model -from mesa.time import BaseScheduler - - -class MockAgent(Agent): - """ - Minimalistic agent for testing purposes. - """ - - def __init__(self, unique_id, model, val=0): - super().__init__(unique_id, model) - self.val = val - self.val2 = val - - def step(self): - """ - Increment vals by 1. - """ - self.val += 1 - self.val2 += 1 - - def double_val(self): - return self.val * 2 - - def write_final_values(self): - """ - Write the final value to the appropriate table. - """ - row = {"agent_id": self.unique_id, "final_value": self.val} - self.model.datacollector.add_table_row("Final_Values", row) - - -def agent_function_with_params(agent, multiplier, offset): - return (agent.val * multiplier) + offset - - -class DifferentMockAgent(MockAgent): - # We define a different MockAgent to test for attributes that are present - # only in 1 type of agent, but not the other. - def __init__(self, unique_id, model, val=0): - super().__init__(unique_id, model, val=val) - self.val3 = val + 42 - - -class MockModel(Model): - """ - Minimalistic model for testing purposes. - """ - - schedule = BaseScheduler(None) - - def __init__(self): - super().__init__() - self.schedule = BaseScheduler(self) - self.model_val = 100 - - self.n = 10 - for i in range(self.n): - self.schedule.add(MockAgent(i, self, val=i)) - self.initialize_data_collector( - model_reporters={ - "total_agents": lambda m: m.schedule.get_agent_count(), - "model_value": "model_val", - "model_calc": self.schedule.get_agent_count, - "model_calc_comp": [self.test_model_calc_comp, [3, 4]], - "model_calc_fail": [self.test_model_calc_comp, [12, 0]], - }, - agent_reporters={ - "value": lambda a: a.val, - "value2": "val2", - "double_value": MockAgent.double_val, - "value_with_params": [agent_function_with_params, [2, 3]], - }, - tables={"Final_Values": ["agent_id", "final_value"]}, - ) - - def test_model_calc_comp(self, input1, input2): - if input2 > 0: - return (self.model_val * input1) / input2 - else: - assert ValueError - return None - - def step(self): - self.schedule.step() - self.datacollector.collect(self) - - -class TestDataCollector(unittest.TestCase): - def setUp(self): - """ - Create the model and run it a set number of steps. - """ - self.model = MockModel() - for i in range(7): - if i == 4: - self.model.schedule.remove(self.model.schedule._agents[3]) - self.model.step() - - # Write to table: - for agent in self.model.schedule.agents: - agent.write_final_values() - - def step_assertion(self, model_var): - for element in model_var: - if model_var.index(element) < 4: - assert element == 10 - else: - assert element == 9 - - def test_model_vars(self): - """ - Test model-level variable collection. - """ - data_collector = self.model.datacollector - assert "total_agents" in data_collector.model_vars - assert "model_value" in data_collector.model_vars - assert "model_calc" in data_collector.model_vars - assert "model_calc_comp" in data_collector.model_vars - assert "model_calc_fail" in data_collector.model_vars - length = 8 - assert len(data_collector.model_vars["total_agents"]) == length - assert len(data_collector.model_vars["model_value"]) == length - assert len(data_collector.model_vars["model_calc"]) == length - assert len(data_collector.model_vars["model_calc_comp"]) == length - self.step_assertion(data_collector.model_vars["total_agents"]) - for element in data_collector.model_vars["model_value"]: - assert element == 100 - self.step_assertion(data_collector.model_vars["model_calc"]) - for element in data_collector.model_vars["model_calc_comp"]: - assert element == 75 - for element in data_collector.model_vars["model_calc_fail"]: - assert element is None - - def test_agent_records(self): - """ - Test agent-level variable collection. - """ - data_collector = self.model.datacollector - agent_table = data_collector.get_agent_vars_dataframe() - - assert "double_value" in list(agent_table.columns) - assert "value_with_params" in list(agent_table.columns) - - # Check the double_value column - for (step, agent_id), value in agent_table["double_value"].items(): - expected_value = (step + agent_id) * 2 - self.assertEqual(value, expected_value) - - # Check the value_with_params column - for (step, agent_id), value in agent_table["value_with_params"].items(): - expected_value = ((step + agent_id) * 2) + 3 - self.assertEqual(value, expected_value) - - assert len(data_collector._agent_records) == 8 - for step, records in data_collector._agent_records.items(): - if step < 5: - assert len(records) == 10 - else: - assert len(records) == 9 - - for values in records: - assert len(values) == 6 - - assert "value" in list(agent_table.columns) - assert "value2" in list(agent_table.columns) - assert "value3" not in list(agent_table.columns) - - with self.assertRaises(KeyError): - data_collector._agent_records[8] - - def test_table_rows(self): - """ - Test table collection - """ - data_collector = self.model.datacollector - assert len(data_collector.tables["Final_Values"]) == 2 - assert "agent_id" in data_collector.tables["Final_Values"] - assert "final_value" in data_collector.tables["Final_Values"] - for _key, data in data_collector.tables["Final_Values"].items(): - assert len(data) == 9 - - with self.assertRaises(Exception): - data_collector.add_table_row("error_table", {}) - - with self.assertRaises(Exception): - data_collector.add_table_row("Final_Values", {"final_value": 10}) - - def test_exports(self): - """ - Test DataFrame exports - """ - data_collector = self.model.datacollector - model_vars = data_collector.get_model_vars_dataframe() - agent_vars = data_collector.get_agent_vars_dataframe() - table_df = data_collector.get_table_dataframe("Final_Values") - assert model_vars.shape == (8, 5) - assert agent_vars.shape == (77, 4) - assert table_df.shape == (9, 2) - - with self.assertRaises(Exception): - table_df = data_collector.get_table_dataframe("not a real table") - - -class TestDataCollectorInitialization(unittest.TestCase): - def setUp(self): - self.model = Model() - - def test_initialize_before_scheduler(self): - with self.assertRaises(RuntimeError) as cm: - self.model.initialize_data_collector() - self.assertEqual( - str(cm.exception), - "You must initialize the scheduler (self.schedule) before initializing the data collector.", - ) - - def test_initialize_before_agents_added_to_scheduler(self): - with self.assertRaises(RuntimeError) as cm: - self.model.schedule = BaseScheduler(self) - self.model.initialize_data_collector() - self.assertEqual( - str(cm.exception), - "You must add agents to the scheduler before initializing the data collector.", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_devs.py b/tests/test_devs.py deleted file mode 100644 index 06d3629..0000000 --- a/tests/test_devs.py +++ /dev/null @@ -1,282 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from mesa import Model -from mesa.experimental.devs.eventlist import EventList, Priority, SimulationEvent -from mesa.experimental.devs.simulator import ABMSimulator, DEVSimulator - - -def test_devs_simulator(): - simulator = DEVSimulator() - - # setup - model = MagicMock(spec=Model) - simulator.setup(model) - - assert len(simulator.event_list) == 0 - assert simulator.model == model - assert simulator.time == 0 - - # schedule_event_now - fn1 = MagicMock() - event1 = simulator.schedule_event_now(fn1) - assert event1 in simulator.event_list - assert len(simulator.event_list) == 1 - - # schedule_event_absolute - fn2 = MagicMock() - event2 = simulator.schedule_event_absolute(fn2, 1.0) - assert event2 in simulator.event_list - assert len(simulator.event_list) == 2 - - # schedule_event_relative - fn3 = MagicMock() - event3 = simulator.schedule_event_relative(fn3, 0.5) - assert event3 in simulator.event_list - assert len(simulator.event_list) == 3 - - # run_for - simulator.run_for(0.8) - fn1.assert_called_once() - fn3.assert_called_once() - assert simulator.time == 0.8 - - simulator.run_for(0.2) - fn2.assert_called_once() - assert simulator.time == 1.0 - - simulator.run_for(0.2) - assert simulator.time == 1.2 - - with pytest.raises(ValueError): - simulator.schedule_event_absolute(fn2, 0.5) - - # cancel_event - simulator = DEVSimulator() - model = MagicMock(spec=Model) - simulator.setup(model) - fn = MagicMock() - event = simulator.schedule_event_relative(fn, 0.5) - simulator.cancel_event(event) - assert event.CANCELED - - # simulator reset - simulator.reset() - assert len(simulator.event_list) == 0 - assert simulator.model is None - assert simulator.time == 0.0 - - -def test_abm_simulator(): - simulator = ABMSimulator() - - # setup - model = MagicMock(spec=Model) - simulator.setup(model) - - # schedule_event_next_tick - fn = MagicMock() - simulator.schedule_event_next_tick(fn) - assert len(simulator.event_list) == 2 - - simulator.run_for(3) - assert model.step.call_count == 3 - assert simulator.time == 2 - - -def test_simulation_event(): - some_test_function = MagicMock() - - time = 10 - event = SimulationEvent( - time, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - - assert event.time == time - assert event.fn() is some_test_function - assert event.function_args == [] - assert event.function_kwargs == {} - assert event.priority == Priority.DEFAULT - - # execute - event.execute() - some_test_function.assert_called_once() - - with pytest.raises(Exception): - SimulationEvent( - time, None, priority=Priority.DEFAULT, function_args=[], function_kwargs={} - ) - - # check calling with arguments - some_test_function = MagicMock() - event = SimulationEvent( - time, - some_test_function, - priority=Priority.DEFAULT, - function_args=["1"], - function_kwargs={"x": 2}, - ) - event.execute() - some_test_function.assert_called_once_with("1", x=2) - - # check if we pass over deletion of callable silently because of weakrefs - def some_test_function(x, y): - return x + y - - event = SimulationEvent(time, some_test_function, priority=Priority.DEFAULT) - del some_test_function - event.execute() - - # cancel - some_test_function = MagicMock() - event = SimulationEvent( - time, - some_test_function, - priority=Priority.DEFAULT, - function_args=["1"], - function_kwargs={"x": 2}, - ) - event.cancel() - assert event.fn is None - assert event.function_args == [] - assert event.function_kwargs == {} - assert event.priority == Priority.DEFAULT - assert event.CANCELED - - # comparison for sorting - event1 = SimulationEvent( - 10, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - event2 = SimulationEvent( - 10, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - assert event1 < event2 # based on just unique_id as tiebraker - - event1 = SimulationEvent( - 11, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - event2 = SimulationEvent( - 10, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - assert event1 > event2 - - event1 = SimulationEvent( - 10, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - event2 = SimulationEvent( - 10, - some_test_function, - priority=Priority.HIGH, - function_args=[], - function_kwargs={}, - ) - assert event1 > event2 - - -def test_eventlist(): - event_list = EventList() - - assert len(event_list._events) == 0 - assert isinstance(event_list._events, list) - assert event_list.is_empty() - - # add event - some_test_function = MagicMock() - event = SimulationEvent( - 1, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - event_list.add_event(event) - assert len(event_list) == 1 - assert event in event_list - - # remove event - event_list.remove(event) - assert len(event_list) == 1 - assert event.CANCELED - - # peak ahead - event_list = EventList() - for i in range(10): - event = SimulationEvent( - i, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - event_list.add_event(event) - events = event_list.peak_ahead(2) - assert len(events) == 2 - assert events[0].time == 0 - assert events[1].time == 1 - - events = event_list.peak_ahead(11) - assert len(events) == 10 - - event_list._events[6].cancel() - events = event_list.peak_ahead(10) - assert len(events) == 9 - - event_list = EventList() - with pytest.raises(Exception): - event_list.peak_ahead() - - # pop event - event_list = EventList() - for i in range(10): - event = SimulationEvent( - i, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - event_list.add_event(event) - event = event_list.pop_event() - assert event.time == 0 - - event_list = EventList() - event = SimulationEvent( - 9, - some_test_function, - priority=Priority.DEFAULT, - function_args=[], - function_kwargs={}, - ) - event_list.add_event(event) - event.cancel() - with pytest.raises(Exception): - event_list.pop_event() - - # clear - event_list.clear() - assert len(event_list) == 0 diff --git a/tests/test_distance_functions.py b/tests/test_distance_functions.py index 2bdae8b..15d9467 100644 --- a/tests/test_distance_functions.py +++ b/tests/test_distance_functions.py @@ -1,5 +1,5 @@ import unittest -from democracy_sim.distance_functions import * +from src.utils.distance_functions import * import numpy as np from itertools import combinations diff --git a/tests/test_end_to_end_viz.sh b/tests/test_end_to_end_viz.sh deleted file mode 100755 index 04405b8..0000000 --- a/tests/test_end_to_end_viz.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -cd examples/Flockers -python run.py & -PID=$! -sleep 3 -curl localhost:8521 | grep Boids -kill $PID diff --git a/tests/test_examples.py b/tests/test_examples.py deleted file mode 100644 index e5c0381..0000000 --- a/tests/test_examples.py +++ /dev/null @@ -1,68 +0,0 @@ -import contextlib -import importlib -import os.path -import sys -import unittest - - -def classcase(name): - return "".join(x.capitalize() for x in name.replace("-", "_").split("_")) - - -@unittest.skip( - "Skipping TextExamples, because examples folder was moved. More discussion needed." -) -class TestExamples(unittest.TestCase): - """ - Test examples' models. This creates a model object and iterates it through - some steps. The idea is to get code coverage, rather than to test the - details of each example's model. - """ - - EXAMPLES = os.path.abspath(os.path.join(os.path.dirname(__file__), "../examples")) - - @contextlib.contextmanager - def active_example_dir(self, example): - "save and restore sys.path and sys.modules" - old_sys_path = sys.path[:] - old_sys_modules = sys.modules.copy() - old_cwd = os.getcwd() - example_path = os.path.abspath(os.path.join(self.EXAMPLES, example)) - try: - sys.path.insert(0, example_path) - os.chdir(example_path) - yield - finally: - os.chdir(old_cwd) - added = [m for m in sys.modules if m not in old_sys_modules] - for mod in added: - del sys.modules[mod] - sys.modules.update(old_sys_modules) - sys.path[:] = old_sys_path - - def test_examples(self): - for example in os.listdir(self.EXAMPLES): - if not os.path.isdir(os.path.join(self.EXAMPLES, example)): - continue - if hasattr(self, f"test_{example.replace('-', '_')}"): - # non-standard example; tested below - continue - - print(f"testing example {example!r}") - with self.active_example_dir(example): - try: - # epstein_civil_violence.py at the top level - mod = importlib.import_module("model") - server = importlib.import_module("server") - server.server.render_model() - except ImportError: - # /epstein_civil_violence.py - mod = importlib.import_module(f"{example.replace('-', '_')}.model") - server = importlib.import_module( - f"{example.replace('-', '_')}.server" - ) - server.server.render_model() - model_class = getattr(mod, classcase(example)) - model = model_class() - for _ in range(10): - model.step() diff --git a/tests/test_grid.py b/tests/test_grid.py deleted file mode 100644 index be68b1f..0000000 --- a/tests/test_grid.py +++ /dev/null @@ -1,507 +0,0 @@ -""" -Test the Grid objects. -""" - -import random -import unittest -from unittest.mock import Mock, patch - -from mesa.space import HexSingleGrid, MultiGrid, SingleGrid - -# Initial agent positions for testing -# -# --- visual aid ---- -# 0 0 0 -# 1 1 0 -# 0 1 0 -# 1 0 1 -# 0 0 1 -# ------------------- -TEST_GRID = [[0, 1, 0, 1, 0, 0], [0, 0, 1, 1, 0, 1], [1, 1, 0, 0, 0, 1]] - - -class MockAgent: - """ - Minimalistic agent for testing purposes. - """ - - def __init__(self, unique_id): - self.random = random.Random(0) - self.unique_id = unique_id - self.pos = None - - -class TestSingleGrid(unittest.TestCase): - """ - Testing a non-toroidal singlegrid. - """ - - torus = False - - def setUp(self): - """ - Create a test non-toroidal grid and populate it with Mock Agents - """ - # The height needs to be even to test the edge case described in PR #1517 - height = 6 # height of grid - width = 3 # width of grid - self.grid = SingleGrid(width, height, self.torus) - self.agents = [] - counter = 0 - for x in range(width): - for y in range(height): - if TEST_GRID[x][y] == 0: - continue - counter += 1 - # Create and place the mock agent - a = MockAgent(counter) - self.agents.append(a) - self.grid.place_agent(a, (x, y)) - - def test_agent_positions(self): - """ - Ensure that the agents are all placed properly. - """ - for agent in self.agents: - x, y = agent.pos - assert self.grid[x][y] == agent - - def test_cell_agent_reporting(self): - """ - Ensure that if an agent is in a cell, get_cell_list_contents accurately - reports that fact. - """ - for agent in self.agents: - x, y = agent.pos - assert agent in self.grid.get_cell_list_contents([(x, y)]) - - def test_listfree_cell_agent_reporting(self): - """ - Ensure that if an agent is in a cell, get_cell_list_contents accurately - reports that fact, even when single position is not wrapped in a list. - """ - for agent in self.agents: - x, y = agent.pos - assert agent in self.grid.get_cell_list_contents((x, y)) - - def test_iter_cell_agent_reporting(self): - """ - Ensure that if an agent is in a cell, iter_cell_list_contents - accurately reports that fact. - """ - for agent in self.agents: - x, y = agent.pos - assert agent in self.grid.iter_cell_list_contents([(x, y)]) - - def test_listfree_iter_cell_agent_reporting(self): - """ - Ensure that if an agent is in a cell, iter_cell_list_contents - accurately reports that fact, even when single position is not - wrapped in a list. - """ - for agent in self.agents: - x, y = agent.pos - assert agent in self.grid.iter_cell_list_contents((x, y)) - - def test_neighbors(self): - """ - Test the base neighborhood methods on the non-toroid. - """ - - neighborhood = self.grid.get_neighborhood((1, 1), moore=True) - assert len(neighborhood) == 8 - - neighborhood = self.grid.get_neighborhood((1, 4), moore=False) - assert len(neighborhood) == 4 - - neighborhood = self.grid.get_neighborhood((1, 4), moore=True) - assert len(neighborhood) == 8 - - neighborhood = self.grid.get_neighborhood((0, 0), moore=False) - assert len(neighborhood) == 2 - - with self.assertRaises(Exception): - neighbors = self.grid.get_neighbors((4, 1), moore=False) - - neighbors = self.grid.get_neighbors((1, 1), moore=False, include_center=True) - assert len(neighbors) == 3 - - neighbors = self.grid.get_neighbors((1, 3), moore=False, radius=2) - assert len(neighbors) == 3 - - def test_coord_iter(self): - ci = self.grid.coord_iter() - - # no agent in first space - first = next(ci) - assert first[0] is None - assert first[1] == (0, 0) - - # first agent in the second space - second = next(ci) - assert second[0].unique_id == 1 - assert second[0].pos == (0, 1) - assert second[1] == (0, 1) - - def test_agent_move(self): - # get the agent at [0, 1] - agent = self.agents[0] - self.grid.move_agent(agent, (1, 0)) - assert agent.pos == (1, 0) - # move it off the torus and check for the exception - if not self.grid.torus: - with self.assertRaises(Exception): - self.grid.move_agent(agent, [-1, 1]) - with self.assertRaises(Exception): - self.grid.move_agent(agent, [1, self.grid.height + 1]) - else: - self.grid.move_agent(agent, [0, -1]) - assert agent.pos == (0, self.grid.height - 1) - self.grid.move_agent(agent, [1, self.grid.height]) - assert agent.pos == (1, 0) - - def test_agent_remove(self): - agent = self.agents[0] - x, y = agent.pos - self.grid.remove_agent(agent) - assert agent.pos is None - assert self.grid[x][y] is None - - def test_swap_pos(self): - # Swap agents positions - agent_a, agent_b = list(filter(None, self.grid))[:2] - pos_a = agent_a.pos - pos_b = agent_b.pos - - self.grid.swap_pos(agent_a, agent_b) - - assert agent_a.pos == pos_b - assert agent_b.pos == pos_a - assert self.grid[pos_a] == agent_b - assert self.grid[pos_b] == agent_a - - # Swap the same agents - self.grid.swap_pos(agent_a, agent_a) - - assert agent_a.pos == pos_b - assert self.grid[pos_b] == agent_a - - # Raise for agents not on the grid - self.grid.remove_agent(agent_a) - self.grid.remove_agent(agent_b) - - id_a = agent_a.unique_id - id_b = agent_b.unique_id - e_message = f", - not on the grid" - with self.assertRaisesRegex(Exception, e_message): - self.grid.swap_pos(agent_a, agent_b) - - -class TestSingleGridTorus(TestSingleGrid): - """ - Testing the toroidal singlegrid. - """ - - torus = True - - def test_neighbors(self): - """ - Test the toroidal neighborhood methods. - """ - - neighborhood = self.grid.get_neighborhood((1, 1), moore=True) - assert len(neighborhood) == 8 - - neighborhood = self.grid.get_neighborhood((1, 4), moore=True) - assert len(neighborhood) == 8 - - neighborhood = self.grid.get_neighborhood((0, 0), moore=False) - assert len(neighborhood) == 4 - - # here we test the edge case described in PR #1517 using a radius - # measuring half of the grid height - neighborhood = self.grid.get_neighborhood((0, 0), moore=True, radius=3) - assert len(neighborhood) == 17 - - neighborhood = self.grid.get_neighborhood((1, 1), moore=False, radius=3) - assert len(neighborhood) == 15 - - neighbors = self.grid.get_neighbors((1, 4), moore=False) - assert len(neighbors) == 2 - - neighbors = self.grid.get_neighbors((1, 4), moore=True) - assert len(neighbors) == 4 - - neighbors = self.grid.get_neighbors((1, 1), moore=False, include_center=True) - assert len(neighbors) == 3 - - neighbors = self.grid.get_neighbors((1, 3), moore=False, radius=2) - assert len(neighbors) == 3 - - -class TestSingleGridEnforcement(unittest.TestCase): - """ - Test the enforcement in SingleGrid. - """ - - def setUp(self): - """ - Create a test non-toroidal grid and populate it with Mock Agents - """ - width = 3 - height = 5 - self.grid = SingleGrid(width, height, True) - self.agents = [] - counter = 0 - for x in range(width): - for y in range(height): - if TEST_GRID[x][y] == 0: - continue - counter += 1 - # Create and place the mock agent - a = MockAgent(counter) - self.agents.append(a) - self.grid.place_agent(a, (x, y)) - self.num_agents = len(self.agents) - - @patch.object(MockAgent, "model", create=True) - def test_enforcement(self, mock_model): - """ - Test the SingleGrid empty count and enforcement. - """ - - assert len(self.grid.empties) == 9 - a = MockAgent(100) - with self.assertRaises(Exception): - self.grid.place_agent(a, (0, 1)) - - # Place the agent in an empty cell - mock_model.schedule.get_agent_count = Mock(side_effect=lambda: len(self.agents)) - self.grid.move_to_empty(a) - self.num_agents += 1 - # Test whether after placing, the empty cells are reduced by 1 - assert a.pos not in self.grid.empties - assert len(self.grid.empties) == 8 - for _i in range(10): - self.grid.move_to_empty(a) - assert len(self.grid.empties) == 8 - - # Place agents until the grid is full - empty_cells = len(self.grid.empties) - for i in range(empty_cells): - a = MockAgent(101 + i) - self.grid.move_to_empty(a) - self.num_agents += 1 - assert len(self.grid.empties) == 0 - - a = MockAgent(110) - with self.assertRaises(Exception): - self.grid.move_to_empty(a) - with self.assertRaises(Exception): - self.move_to_empty(self.agents[0]) - - -# Number of agents at each position for testing -# Initial agent positions for testing -# -# --- visual aid ---- -# 0 0 0 -# 2 0 3 -# 0 5 0 -# 1 1 0 -# 0 0 0 -# ------------------- -TEST_MULTIGRID = [[0, 1, 0, 2, 0], [0, 1, 5, 0, 0], [0, 0, 0, 3, 0]] - - -class TestMultiGrid(unittest.TestCase): - """ - Testing a toroidal MultiGrid - """ - - torus = True - - def setUp(self): - """ - Create a test non-toroidal grid and populate it with Mock Agents - """ - width = 3 - height = 5 - self.grid = MultiGrid(width, height, self.torus) - self.agents = [] - counter = 0 - for x in range(width): - for y in range(height): - for _i in range(TEST_MULTIGRID[x][y]): - counter += 1 - # Create and place the mock agent - a = MockAgent(counter) - self.agents.append(a) - self.grid.place_agent(a, (x, y)) - - def test_agent_positions(self): - """ - Ensure that the agents are all placed properly on the MultiGrid. - """ - for agent in self.agents: - x, y = agent.pos - assert agent in self.grid[x][y] - - def test_neighbors(self): - """ - Test the toroidal MultiGrid neighborhood methods. - """ - - neighborhood = self.grid.get_neighborhood((1, 1), moore=True) - assert len(neighborhood) == 8 - - neighborhood = self.grid.get_neighborhood((1, 4), moore=True) - assert len(neighborhood) == 8 - - neighborhood = self.grid.get_neighborhood((0, 0), moore=False) - assert len(neighborhood) == 4 - - neighbors = self.grid.get_neighbors((1, 4), moore=False) - assert len(neighbors) == 0 - - neighbors = self.grid.get_neighbors((1, 4), moore=True) - assert len(neighbors) == 5 - - neighbors = self.grid.get_neighbors((1, 1), moore=False, include_center=True) - assert len(neighbors) == 7 - - neighbors = self.grid.get_neighbors((1, 3), moore=False, radius=2) - assert len(neighbors) == 11 - - -class TestHexSingleGrid(unittest.TestCase): - """ - Testing a hexagonal singlegrid. - """ - - def setUp(self): - """ - Create a test non-toroidal grid and populate it with Mock Agents - """ - width = 3 - height = 5 - self.grid = HexSingleGrid(width, height, torus=False) - self.agents = [] - counter = 0 - for x in range(width): - for y in range(height): - if TEST_GRID[x][y] == 0: - continue - counter += 1 - # Create and place the mock agent - a = MockAgent(counter) - self.agents.append(a) - self.grid.place_agent(a, (x, y)) - - def test_neighbors(self): - """ - Test the hexagonal neighborhood methods on the non-toroid. - """ - neighborhood = self.grid.get_neighborhood((1, 1)) - assert len(neighborhood) == 6 - - neighborhood = self.grid.get_neighborhood((0, 2)) - assert len(neighborhood) == 4 - - neighborhood = self.grid.get_neighborhood((1, 0)) - assert len(neighborhood) == 3 - - neighborhood = self.grid.get_neighborhood((1, 4)) - assert len(neighborhood) == 5 - - neighborhood = self.grid.get_neighborhood((0, 4)) - assert len(neighborhood) == 2 - - neighborhood = self.grid.get_neighborhood((0, 0)) - assert len(neighborhood) == 3 - - neighborhood = self.grid.get_neighborhood((1, 1), include_center=True) - assert len(neighborhood) == 7 - - neighborhood = self.grid.get_neighborhood((0, 0), radius=4) - assert len(neighborhood) == 13 - assert sum(x + y for x, y in neighborhood) == 39 - - -class TestHexSingleGridTorus(TestSingleGrid): - """ - Testing a hexagonal toroidal singlegrid. - """ - - def setUp(self): - """ - Create a test non-toroidal grid and populate it with Mock Agents - """ - width = 3 - height = 5 - self.grid = HexSingleGrid(width, height, torus=True) - self.agents = [] - counter = 0 - for x in range(width): - for y in range(height): - if TEST_GRID[x][y] == 0: - continue - counter += 1 - # Create and place the mock agent - a = MockAgent(counter) - self.agents.append(a) - self.grid.place_agent(a, (x, y)) - - def test_neighbors(self): - """ - Test the hexagonal neighborhood methods on the toroid. - """ - neighborhood = self.grid.get_neighborhood((1, 1)) - assert len(neighborhood) == 6 - - neighborhood = self.grid.get_neighborhood((1, 1), include_center=True) - assert len(neighborhood) == 7 - - neighborhood = self.grid.get_neighborhood((0, 0)) - assert len(neighborhood) == 6 - - neighborhood = self.grid.get_neighborhood((2, 4)) - assert len(neighborhood) == 6 - - neighborhood = self.grid.get_neighborhood((1, 1), include_center=True, radius=2) - assert len(neighborhood) == 13 - - neighborhood = self.grid.get_neighborhood((0, 0), radius=4) - assert len(neighborhood) == 14 - assert sum(x + y for x, y in neighborhood) == 45 - - -class TestIndexing: - # Create a grid where the content of each coordinate is a tuple of its coordinates - grid = SingleGrid(3, 5, True) - for _, pos in grid.coord_iter(): - x, y = pos - grid._grid[x][y] = pos - - def test_int(self): - assert self.grid[0][0] == (0, 0) - - def test_tuple(self): - assert self.grid[1, 1] == (1, 1) - - def test_list(self): - assert self.grid[(0, 0), (1, 1)] == [(0, 0), (1, 1)] - assert self.grid[(0, 0), (5, 3)] == [(0, 0), (2, 3)] - - def test_torus(self): - assert self.grid[3, 5] == (0, 0) - - def test_slice(self): - assert self.grid[:, 0] == [(0, 0), (1, 0), (2, 0)] - assert self.grid[::-1, 0] == [(2, 0), (1, 0), (0, 0)] - assert self.grid[1, :] == [(1, 0), (1, 1), (1, 2), (1, 3), (1, 4)] - assert self.grid[:, :] == [(x, y) for x in range(3) for y in range(5)] - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_import_namespace.py b/tests/test_import_namespace.py deleted file mode 100644 index c3bacc9..0000000 --- a/tests/test_import_namespace.py +++ /dev/null @@ -1,27 +0,0 @@ -def test_import(): - # This tests the new, simpler Mesa namespace. See - # https://github.com/projectmesa/mesa/pull/1294. - import mesa - import mesa.flat as mf - from mesa.time import RandomActivation - - _ = mesa.time.RandomActivation - _ = RandomActivation - _ = mf.RandomActivation - - from mesa.space import MultiGrid - - _ = mesa.space.MultiGrid - _ = MultiGrid - _ = mf.MultiGrid - - from mesa.datacollection import DataCollector - - _ = DataCollector - _ = mesa.DataCollector - _ = mf.DataCollector - - from mesa.batchrunner import batch_run - - _ = batch_run - _ = mesa.batch_run diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py deleted file mode 100644 index cfd60cd..0000000 --- a/tests/test_lifespan.py +++ /dev/null @@ -1,95 +0,0 @@ -import unittest - -import numpy as np - -from mesa import Agent, Model -from mesa.datacollection import DataCollector -from mesa.time import RandomActivation - - -class LifeTimeModel(Model): - """Simple model for running models with a finite life""" - - def __init__(self, agent_lifetime=1, n_agents=10): - super().__init__() - - self.agent_lifetime = agent_lifetime - self.n_agents = n_agents - - # keep track of the the remaining life of an agent and - # how many ticks it has seen - self.datacollector = DataCollector( - agent_reporters={ - "remaining_life": lambda a: a.remaining_life, - "steps": lambda a: a.steps, - } - ) - - self.current_ID = 0 - self.schedule = RandomActivation(self) - - for _ in range(n_agents): - self.schedule.add( - FiniteLifeAgent(self.next_id(), self.agent_lifetime, self) - ) - - def step(self): - """Add agents back to n_agents in each step""" - self.datacollector.collect(self) - self.schedule.step() - - if len(self.schedule.agents) < self.n_agents: - for _ in range(self.n_agents - len(self.schedule.agents)): - self.schedule.add( - FiniteLifeAgent(self.next_id(), self.agent_lifetime, self) - ) - - def run_model(self, step_count=100): - for _ in range(step_count): - self.step() - - -class FiniteLifeAgent(Agent): - """An agent that is supposed to live for a finite number of ticks. - Also has a 10% chance of dying in each tick. - """ - - def __init__(self, unique_id, lifetime, model): - super().__init__(unique_id, model) - self.remaining_life = lifetime - self.steps = 0 - self.model = model - - def step(self): - inactivated = self.inactivate() - if not inactivated: - self.steps += 1 # keep track of how many ticks are seen - if np.random.binomial(1, 0.1) != 0: # 10% chance of dying - self.model.schedule.remove(self) - - def inactivate(self): - self.remaining_life -= 1 - if self.remaining_life < 0: - self.model.schedule.remove(self) - return True - return False - - -class TestAgentLifespan(unittest.TestCase): - def setUp(self): - self.model = LifeTimeModel() - self.model.run_model() - self.df = self.model.datacollector.get_agent_vars_dataframe() - self.df = self.df.reset_index() - - def test_ticks_seen(self): - """Each agent should be activated no more than one time""" - assert self.df.steps.max() == 1 - - def test_agent_lifetime(self): - lifetimes = self.df.groupby(["AgentID"]).agg({"Step": len}) - assert lifetimes.Step.max() == 2 - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index b6bba37..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,34 +0,0 @@ -import os -import sys -import unittest -from unittest.mock import patch - -from click.testing import CliRunner - -from mesa.main import cli - - -class TestCli(unittest.TestCase): - """ - Test CLI commands - """ - - def setUp(self): - self.old_sys_path = sys.path[:] - self.runner = CliRunner() - - def tearDown(self): - sys.path[:] = self.old_sys_path - - @unittest.skip( - "Skipping test_run, because examples folder was moved. More discussion needed." - ) - def test_run(self): - with patch("mesa.visualization_old.ModularServer") as ModularServer: # noqa: N806 - example_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "../examples/wolf_sheep") - ) - with self.runner.isolated_filesystem(): - result = self.runner.invoke(cli, ["runserver", example_dir]) - assert result.exit_code == 0, result.output - assert ModularServer().launch.call_count == 1 diff --git a/tests/test_majority_rule.py b/tests/test_majority_rule.py index be68c3f..e674bcf 100644 --- a/tests/test_majority_rule.py +++ b/tests/test_majority_rule.py @@ -1,6 +1,6 @@ import numpy as np import time -from democracy_sim.social_welfare_functions import majority_rule +from src.utils.social_welfare_functions import majority_rule # Simple and standard cases (lower values = higher rank) diff --git a/tests/test_model.py b/tests/test_model.py deleted file mode 100644 index 874d45f..0000000 --- a/tests/test_model.py +++ /dev/null @@ -1,53 +0,0 @@ -from mesa.agent import Agent -from mesa.model import Model - - -def test_model_set_up(): - model = Model() - assert model.running is True - assert model.schedule is None - assert model.current_id == 0 - assert model.current_id + 1 == model.next_id() - assert model.current_id == 1 - model.step() - - -def test_running(): - class TestModel(Model): - steps = 0 - - def step(self): - """Increase steps until 10.""" - self.steps += 1 - if self.steps == 10: - self.running = False - - model = TestModel() - model.run_model() - - -def test_seed(seed=23): - model = Model(seed=seed) - assert model._seed == seed - model2 = Model(seed=seed + 1) - assert model2._seed == seed + 1 - assert model._seed == seed - - -def test_reset_randomizer(newseed=42): - model = Model() - oldseed = model._seed - model.reset_randomizer() - assert model._seed == oldseed - model.reset_randomizer(seed=newseed) - assert model._seed == newseed - - -def test_agent_types(): - class TestAgent(Agent): - pass - - model = Model() - test_agent = TestAgent(model.next_id(), model) - assert test_agent in model.agents - assert type(test_agent) in model.agent_types diff --git a/tests/test_participation_area_agent.py b/tests/test_participation_area_agent.py index dc84c86..3b1fcb9 100644 --- a/tests/test_participation_area_agent.py +++ b/tests/test_participation_area_agent.py @@ -1,11 +1,11 @@ import unittest import random import numpy as np -from democracy_sim.participation_model import Area -from democracy_sim.participation_agent import VoteAgent -from .test_participation_model import TestParticipationModel, num_agents -from democracy_sim.social_welfare_functions import majority_rule, approval_voting -from democracy_sim.distance_functions import kendall_tau, spearman +from src.models.participation_model import Area +from src.agents.vote_agent import VoteAgent +from .test_participation_model import TestParticipationModel, model_cfg +from src.utils.social_welfare_functions import majority_rule, approval_voting +from src.utils.distance_functions import kendall_tau, spearman class TestArea(unittest.TestCase): @@ -68,7 +68,7 @@ def test_conduct_election(self): def test_adding_new_area_and_agent_within_it(self): # Additional area and agent personality = random.choice(self.model.personalities) - a = VoteAgent(num_agents + 1, self.model, pos=(0, 0), + a = VoteAgent(model_cfg["num_agents"] + 1, self.model, pos=(0, 0), personality=personality, assets=25) additional_test_area = Area(self.model.num_areas + 1, model=self.model, height=5, diff --git a/tests/test_participation_model.py b/tests/test_participation_model.py index 31cfaa6..6a697dc 100644 --- a/tests/test_participation_model.py +++ b/tests/test_participation_model.py @@ -1,43 +1,19 @@ import unittest -from democracy_sim.participation_model import (ParticipationModel, Area, - distance_functions, - social_welfare_functions) -from democracy_sim.model_setup import (grid_rows as height, grid_cols as width, - num_agents, num_colors, num_areas, - num_personalities, common_assets, mu, - known_cells, - election_impact_on_mutation as e_impact, - draw_borders, rule_idx, distance_idx, - color_heterogeneity as heterogeneity, - color_patches_steps, av_area_height, - av_area_width, area_size_variance, - patch_power, election_costs, max_reward) +from src.models.participation_model import (ParticipationModel, Area, + distance_functions, + social_welfare_functions) +from src.config.loader import load_config import mesa +config = load_config() +model_cfg = config.model.model_dump() +vis_cfg = config.visualization.model_dump() + class TestParticipationModel(unittest.TestCase): def setUp(self): - self.model = ParticipationModel(height=height, width=width, - num_agents=num_agents, - num_colors=num_colors, - num_personalities=num_personalities, - known_cells=known_cells, - common_assets=common_assets, mu=mu, - election_impact_on_mutation=e_impact, - num_areas=num_areas, - draw_borders=draw_borders, - election_costs=election_costs, - rule_idx=rule_idx, - distance_idx=distance_idx, - heterogeneity=heterogeneity, - color_patches_steps=color_patches_steps, - av_area_height=av_area_height, - av_area_width=av_area_width, - area_size_variance=area_size_variance, - patch_power=patch_power, - max_reward=max_reward, - show_area_stats=False) + self.model = ParticipationModel(**model_cfg) # def test_empty_model(self): # # TODO: Test empty model @@ -52,21 +28,21 @@ def test_initialization(self): # TODO ... more tests def test_model_options(self): - self.assertEqual(self.model.num_agents, num_agents) - self.assertEqual(self.model.num_colors, num_colors) - self.assertEqual(self.model.num_areas, num_areas) - self.assertEqual(self.model.area_size_variance, area_size_variance) - self.assertEqual(self.model.draw_borders, draw_borders) - v_rule = social_welfare_functions[rule_idx] - dist_func = distance_functions[distance_idx] - self.assertEqual(self.model.common_assets, common_assets) + self.assertEqual(self.model.num_agents, model_cfg["num_agents"]) + self.assertEqual(self.model.num_colors, model_cfg["num_colors"]) + self.assertEqual(self.model.num_areas, model_cfg["num_areas"]) + self.assertEqual(self.model.area_size_variance, + model_cfg["area_size_variance"]) + v_rule = social_welfare_functions[model_cfg["rule_idx"]] + dist_func = distance_functions[model_cfg["distance_idx"]] + self.assertEqual(self.model.common_assets, model_cfg["common_assets"]) self.assertEqual(self.model.voting_rule, v_rule) self.assertEqual(self.model.distance_func, dist_func) - self.assertEqual(self.model.election_costs, election_costs) + self.assertEqual(self.model.election_costs, model_cfg["election_costs"]) def test_create_color_distribution(self): eq_dst = self.model.create_color_distribution(heterogeneity=0) - self.assertEqual([1/num_colors for _ in eq_dst], eq_dst) + self.assertEqual([1/model_cfg["num_colors"] for _ in eq_dst], eq_dst) print(f"Color distribution with heterogeneity=0: {eq_dst}") het_dst = self.model.create_color_distribution(heterogeneity=1) print(f"Color distribution with heterogeneity=1: {het_dst}") @@ -79,7 +55,7 @@ def test_create_color_distribution(self): def test_distribution_of_personalities(self): p_dist = self.model.personality_distribution self.assertAlmostEqual(sum(p_dist), 1.0) - self.assertEqual(len(p_dist), num_personalities) + self.assertEqual(len(p_dist), model_cfg["num_personalities"]) voting_agents = self.model.voting_agents nr_agents = self.model.num_agents personalities = list(self.model.personalities) @@ -93,7 +69,7 @@ def test_distribution_of_personalities(self): self.assertEqual(len(real_dist), len(p_dist)) self.assertAlmostEqual(float(sum(real_dist)), 1.0) # Compare each value - my_delta = 0.4 / num_personalities # The more personalities, the smaller the delta + my_delta = 0.4 / model_cfg["num_personalities"] # The more personalities, the smaller the delta for p_dist_val, real_p_dist_val in zip(p_dist, real_dist): self.assertAlmostEqual(p_dist_val, real_p_dist_val, delta=my_delta) diff --git a/tests/test_participation_voting_agent.py b/tests/test_participation_voting_agent.py index 4f49a6a..8a3888b 100644 --- a/tests/test_participation_voting_agent.py +++ b/tests/test_participation_voting_agent.py @@ -1,10 +1,16 @@ -from .test_participation_model import * -from democracy_sim.participation_model import Area -from democracy_sim.participation_agent import VoteAgent, combine_and_normalize +from .test_participation_model import TestParticipationModel +import unittest +from src.models.participation_model import Area +from src.agents.vote_agent import VoteAgent, combine_and_normalize +from src.config.loader import load_config import numpy as np import random +config = load_config() +model_cfg = config.model +vis_cfg = config.visualization + class TestVotingAgent(unittest.TestCase): def setUp(self): @@ -12,8 +18,8 @@ def setUp(self): test_model.setUp() self.model = test_model.model personality = random.choice(self.model.personalities) - self.agent = VoteAgent(num_agents + 1, self.model, pos=(0, 0), - personality=personality, assets=25) + self.agent = VoteAgent(model_cfg.num_agents + 1, self.model, + pos=(0, 0), personality=personality, assets=25) self.additional_test_area = Area(self.model.num_areas + 1, model=self.model, height=5, width=5, size_variance=0) @@ -51,11 +57,11 @@ def test_combine_and_normalize(self): print(f"Assumed opt. distribution with factor {a_factor}: \n{comb}") # Validation if a_factor == 0.0: - self.assertEqual(list(comb), list(est_dist)) + self.assertTrue(np.allclose(comb, est_dist, atol=1e-12)) elif a_factor == 1.0: if sum(own_prefs) != 1.0: own_prefs = own_prefs / sum(own_prefs) - self.assertEqual(list(comb), list(own_prefs)) + self.assertTrue(np.allclose(comb, own_prefs, atol=1e-12)) self.assertTrue(np.isclose(sum(comb), 1.0, atol=1e-8)) def test_compute_assumed_opt_dist(self): diff --git a/tests/test_pers_dist.py b/tests/test_pers_dist.py index 1142ce8..7b2950d 100644 --- a/tests/test_pers_dist.py +++ b/tests/test_pers_dist.py @@ -1,10 +1,10 @@ import numpy as np import matplotlib.pyplot as plt -def create_gaussian_distribution(size): +def create_gaussian_distribution(size: int) -> np.ndarray: # Generate a normal distribution - rng = np.random.default_rng() - dist = rng.normal(0, 1, size) + def_rng = np.random.default_rng() + dist = def_rng.normal(0, 1, size) dist.sort() # To create a gaussian curve like array dist = np.abs(dist) # Flip negative values "up" # Normalize the distribution to sum to one @@ -17,23 +17,32 @@ def create_gaussian_distribution(size): return dist # Example usage -nr_options = 20 -gaussian_dist = create_gaussian_distribution(nr_options) -s = gaussian_dist.sum() - -nr_zeroes = gaussian_dist.size - np.count_nonzero(gaussian_dist) -print("There are", nr_zeroes, "zero values in the distribution") - -# Plot the distribution -plt.plot(gaussian_dist) -plt.title("Normalized Gaussian Distribution") -plt.show() - -sample_size = 800 -pool = np.arange(nr_options) -rng = np.random.default_rng() -print(pool.shape) -chosen = rng.choice(pool, sample_size, p=gaussian_dist) - -plt.hist(chosen) -plt.show() \ No newline at end of file +if __name__ == "__main__": + nr_options = 20 + gaussian_dist = create_gaussian_distribution(nr_options) + s = gaussian_dist.sum() + + nr_zeroes = gaussian_dist.size - np.count_nonzero(gaussian_dist) + print("There are", nr_zeroes, "zero values in the distribution") + + # Plot the distribution + plt.plot(gaussian_dist) + plt.title("Normalized Gaussian Distribution") + plt.show() + + sample_size = 800 + pool = np.arange(nr_options) + rng = np.random.default_rng() + print(pool.shape) + chosen = rng.choice(pool, sample_size, p=gaussian_dist) + + plt.hist(chosen) + plt.show() + + +def test_distribution_normalized(): + dist = create_gaussian_distribution(20) + assert np.isclose(dist.sum(), 1.0) + assert (dist >= 0).all() + # Ensure variation (not all equal) + assert np.unique(dist).size > 1 diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py deleted file mode 100644 index 627552e..0000000 --- a/tests/test_scaffold.py +++ /dev/null @@ -1,22 +0,0 @@ -import os -import unittest - -from click.testing import CliRunner - -from mesa.main import cli - - -class ScaffoldTest(unittest.TestCase): - """ - Test mesa project scaffolding command - """ - - @classmethod - def setUpClass(cls): - cls.runner = CliRunner() - - def test_scaffold_creates_project_dir(self): - with self.runner.isolated_filesystem(): - assert not os.path.isdir("example_project") - self.runner.invoke(cli, ["startproject", "--no-input"]) - assert os.path.isdir("example_project") diff --git a/tests/test_set_dimensions.py b/tests/test_set_dimensions.py deleted file mode 100644 index 936218c..0000000 --- a/tests/test_set_dimensions.py +++ /dev/null @@ -1,28 +0,0 @@ -import unittest -from tests.factory import create_default_model - -class TestSetDimensions(unittest.TestCase): - def setUp(self): - self.model = create_default_model( - num_areas=1, - height=10, - width=10, - av_area_height=5, - av_area_width=5, - area_size_variance=0 - ) - - def test_dimensions_no_variance(self): - area = self.model.areas[0] - self.assertEqual(area._width, 5) - self.assertEqual(area._height, 5) - - def test_dimensions_out_of_range(self): - with self.assertRaises(ValueError): - bad_model = create_default_model( - num_areas=1, - av_area_width=5, - av_area_height=5, - area_size_variance=2 - ) - _ = bad_model.areas[0] \ No newline at end of file diff --git a/tests/test_space.py b/tests/test_space.py deleted file mode 100644 index 23cf8c9..0000000 --- a/tests/test_space.py +++ /dev/null @@ -1,1038 +0,0 @@ -import unittest - -import networkx as nx -import numpy as np -import pytest - -from mesa.space import ContinuousSpace, NetworkGrid, PropertyLayer, SingleGrid -from tests.test_grid import MockAgent - -TEST_AGENTS = [(-20, -20), (-20, -20.05), (65, 18)] -TEST_AGENTS_GRID = [(1, 1), (10, 0), (10, 10)] -TEST_AGENTS_NETWORK_SINGLE = [0, 1, 5] -TEST_AGENTS_NETWORK_MULTIPLE = [0, 1, 1] -OUTSIDE_POSITIONS = [(70, 10), (30, 20), (100, 10)] -REMOVAL_TEST_AGENTS = [ - (-20, -20), - (-20, -20.05), - (65, 18), - (0, -11), - (20, 20), - (31, 41), - (55, 32), -] -TEST_AGENTS_PERF = 200000 - - -@pytest.mark.skip(reason="a perf test will slow down the CI") -class TestSpacePerformance(unittest.TestCase): - """ - Testing adding many agents for a continuous space. - """ - - def setUp(self): - """ - Create a test space and populate with Mock Agents. - """ - self.space = ContinuousSpace(10, 10, True, -10, -10) - - def test_agents_add_many(self): - """ - Add many agents - """ - positions = np.random.rand(TEST_AGENTS_PERF, 2) - for i in range(TEST_AGENTS_PERF): - a = MockAgent(i) - pos = [positions[i, 0], positions[i, 1]] - self.space.place_agent(a, pos) - - -class TestSpaceToroidal(unittest.TestCase): - """ - Testing a toroidal continuous space. - """ - - def setUp(self): - """ - Create a test space and populate with Mock Agents. - """ - self.space = ContinuousSpace(70, 20, True, -30, -30) - self.agents = [] - for i, pos in enumerate(TEST_AGENTS): - a = MockAgent(i) - self.agents.append(a) - self.space.place_agent(a, pos) - - def test_agent_positions(self): - """ - Ensure that the agents are all placed properly. - """ - for i, pos in enumerate(TEST_AGENTS): - a = self.agents[i] - assert a.pos == pos - - def test_agent_matching(self): - """ - Ensure that the agents are all placed and indexed properly. - """ - for i, agent in self.space._index_to_agent.items(): - assert agent.pos == tuple(self.space._agent_points[i, :]) - assert i == self.space._agent_to_index[agent] - - def test_distance_calculations(self): - """ - Test toroidal distance calculations. - """ - pos_1 = (-30, -30) - pos_2 = (70, 20) - assert self.space.get_distance(pos_1, pos_2) == 0 - - pos_3 = (-30, -20) - assert self.space.get_distance(pos_1, pos_3) == 10 - - pos_4 = (20, -5) - pos_5 = (20, -15) - assert self.space.get_distance(pos_4, pos_5) == 10 - - pos_6 = (-30, -29) - pos_7 = (21, -5) - assert self.space.get_distance(pos_6, pos_7) == np.sqrt(49**2 + 24**2) - - def test_heading(self): - pos_1 = (-30, -30) - pos_2 = (70, 20) - self.assertEqual((0, 0), self.space.get_heading(pos_1, pos_2)) - - pos_1 = (65, -25) - pos_2 = (-25, -25) - self.assertEqual((10, 0), self.space.get_heading(pos_1, pos_2)) - - def test_neighborhood_retrieval(self): - """ - Test neighborhood retrieval - """ - neighbors_1 = self.space.get_neighbors((-20, -20), 1) - assert len(neighbors_1) == 2 - - neighbors_2 = self.space.get_neighbors((40, -10), 10) - assert len(neighbors_2) == 0 - - neighbors_3 = self.space.get_neighbors((-30, -30), 10) - assert len(neighbors_3) == 1 - - def test_bounds(self): - """ - Test positions outside of boundary - """ - boundary_agents = [] - for i, pos in enumerate(OUTSIDE_POSITIONS): - a = MockAgent(len(self.agents) + i) - boundary_agents.append(a) - self.space.place_agent(a, pos) - - for a, pos in zip(boundary_agents, OUTSIDE_POSITIONS): - adj_pos = self.space.torus_adj(pos) - assert a.pos == adj_pos - - a = self.agents[0] - for pos in OUTSIDE_POSITIONS: - assert self.space.out_of_bounds(pos) - self.space.move_agent(a, pos) - - -class TestSpaceNonToroidal(unittest.TestCase): - """ - Testing a toroidal continuous space. - """ - - def setUp(self): - """ - Create a test space and populate with Mock Agents. - """ - self.space = ContinuousSpace(70, 20, False, -30, -30) - self.agents = [] - for i, pos in enumerate(TEST_AGENTS): - a = MockAgent(i) - self.agents.append(a) - self.space.place_agent(a, pos) - - def test_agent_positions(self): - """ - Ensure that the agents are all placed properly. - """ - for i, pos in enumerate(TEST_AGENTS): - a = self.agents[i] - assert a.pos == pos - - def test_agent_matching(self): - """ - Ensure that the agents are all placed and indexed properly. - """ - for i, agent in self.space._index_to_agent.items(): - assert agent.pos == tuple(self.space._agent_points[i, :]) - assert i == self.space._agent_to_index[agent] - - def test_distance_calculations(self): - """ - Test toroidal distance calculations. - """ - - pos_2 = (70, 20) - pos_3 = (-30, -20) - assert self.space.get_distance(pos_2, pos_3) == 107.70329614269008 - - def test_heading(self): - pos_1 = (-30, -30) - pos_2 = (70, 20) - self.assertEqual((100, 50), self.space.get_heading(pos_1, pos_2)) - - pos_1 = (65, -25) - pos_2 = (-25, -25) - self.assertEqual((-90, 0), self.space.get_heading(pos_1, pos_2)) - - def test_neighborhood_retrieval(self): - """ - Test neighborhood retrieval - """ - neighbors_1 = self.space.get_neighbors((-20, -20), 1) - assert len(neighbors_1) == 2 - - neighbors_2 = self.space.get_neighbors((40, -10), 10) - assert len(neighbors_2) == 0 - - neighbors_3 = self.space.get_neighbors((-30, -30), 10) - assert len(neighbors_3) == 0 - - def test_bounds(self): - """ - Test positions outside of boundary - """ - for i, pos in enumerate(OUTSIDE_POSITIONS): - a = MockAgent(len(self.agents) + i) - with self.assertRaises(Exception): - self.space.place_agent(a, pos) - - a = self.agents[0] - for pos in OUTSIDE_POSITIONS: - assert self.space.out_of_bounds(pos) - with self.assertRaises(Exception): - self.space.move_agent(a, pos) - - -class TestSpaceAgentMapping(unittest.TestCase): - """ - Testing a continuous space for agent mapping during removal. - """ - - def setUp(self): - """ - Create a test space and populate with Mock Agents. - """ - self.space = ContinuousSpace(70, 50, False, -30, -30) - self.agents = [] - for i, pos in enumerate(REMOVAL_TEST_AGENTS): - a = MockAgent(i) - self.agents.append(a) - self.space.place_agent(a, pos) - - def test_remove_first(self): - """ - Test removing the first entry - """ - agent_to_remove = self.agents[0] - self.space.remove_agent(agent_to_remove) - for i, agent in self.space._index_to_agent.items(): - assert agent.pos == tuple(self.space._agent_points[i, :]) - assert i == self.space._agent_to_index[agent] - assert agent_to_remove not in self.space._agent_to_index - assert agent_to_remove.pos is None - with self.assertRaises(Exception): - self.space.remove_agent(agent_to_remove) - - def test_remove_last(self): - """ - Test removing the last entry - """ - agent_to_remove = self.agents[-1] - self.space.remove_agent(agent_to_remove) - for i, agent in self.space._index_to_agent.items(): - assert agent.pos == tuple(self.space._agent_points[i, :]) - assert i == self.space._agent_to_index[agent] - assert agent_to_remove not in self.space._agent_to_index - assert agent_to_remove.pos is None - with self.assertRaises(Exception): - self.space.remove_agent(agent_to_remove) - - def test_remove_middle(self): - """ - Test removing a middle entry - """ - agent_to_remove = self.agents[3] - self.space.remove_agent(agent_to_remove) - for i, agent in self.space._index_to_agent.items(): - assert agent.pos == tuple(self.space._agent_points[i, :]) - assert i == self.space._agent_to_index[agent] - assert agent_to_remove not in self.space._agent_to_index - assert agent_to_remove.pos is None - with self.assertRaises(Exception): - self.space.remove_agent(agent_to_remove) - - -class TestPropertyLayer(unittest.TestCase): - def setUp(self): - self.layer = PropertyLayer("test_layer", 10, 10, 0, dtype=int) - - # Initialization Test - def test_initialization(self): - self.assertEqual(self.layer.name, "test_layer") - self.assertEqual(self.layer.width, 10) - self.assertEqual(self.layer.height, 10) - self.assertTrue(np.array_equal(self.layer.data, np.zeros((10, 10)))) - - # Set Cell Test - def test_set_cell(self): - self.layer.set_cell((5, 5), 1) - self.assertEqual(self.layer.data[5, 5], 1) - - # Set Cells Tests - def test_set_cells_no_condition(self): - self.layer.set_cells(2) - np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 2)) - - def test_set_cells_with_condition(self): - self.layer.set_cell((5, 5), 1) - - def condition(x): - return x == 0 - - self.layer.set_cells(3, condition) - self.assertEqual(self.layer.data[5, 5], 1) - self.assertEqual(self.layer.data[0, 0], 3) - # Check if the sum is correct - self.assertEqual(np.sum(self.layer.data), 3 * 99 + 1) - - def test_set_cells_with_random_condition(self): - # Probability for a cell to be updated - update_probability = 0.5 - - # Define a condition with a random part - def condition(val): - return np.random.rand() < update_probability - - # Apply set_cells - self.layer.set_cells(True, condition) - - # Count the number of cells that were set to True - true_count = np.sum(self.layer.data) - - width = self.layer.width - height = self.layer.height - - # Calculate expected range (with some tolerance for randomness) - expected_min = width * height * update_probability * 0.5 - expected_max = width * height * update_probability * 1.5 - - # Check if the true_count falls within the expected range - assert expected_min <= true_count <= expected_max - - # Modify Cell Test - def test_modify_cell_lambda(self): - self.layer.data = np.zeros((10, 10)) - self.layer.modify_cell((2, 2), lambda x: x + 5) - self.assertEqual(self.layer.data[2, 2], 5) - - def test_modify_cell_ufunc(self): - self.layer.data = np.ones((10, 10)) - self.layer.modify_cell((3, 3), np.add, 4) - self.assertEqual(self.layer.data[3, 3], 5) - - def test_modify_cell_invalid_operation(self): - with self.assertRaises(ValueError): - self.layer.modify_cell((1, 1), np.add) # Missing value for ufunc - - # Modify Cells Test - def test_modify_cells_lambda(self): - self.layer.data = np.zeros((10, 10)) - self.layer.modify_cells(lambda x: x + 2) - np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 2)) - - def test_modify_cells_ufunc(self): - self.layer.data = np.ones((10, 10)) - self.layer.modify_cells(np.multiply, 3) - np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 3)) - - def test_modify_cells_invalid_operation(self): - with self.assertRaises(ValueError): - self.layer.modify_cells(np.add) # Missing value for ufunc - - # Aggregate Property Test - def test_aggregate_property_lambda(self): - self.layer.data = np.arange(100).reshape(10, 10) - result = self.layer.aggregate_property(lambda x: np.sum(x)) - self.assertEqual(result, np.sum(np.arange(100))) - - def test_aggregate_property_ufunc(self): - self.layer.data = np.full((10, 10), 2) - result = self.layer.aggregate_property(np.mean) - self.assertEqual(result, 2) - - # Edge Case: Negative or Zero Dimensions - def test_initialization_negative_dimensions(self): - with self.assertRaises(ValueError): - PropertyLayer("test_layer", -10, 10, 0, dtype=int) - - def test_initialization_zero_dimensions(self): - with self.assertRaises(ValueError): - PropertyLayer("test_layer", 0, 10, 0, dtype=int) - - # Edge Case: Out-of-Bounds Cell Access - def test_set_cell_out_of_bounds(self): - with self.assertRaises(IndexError): - self.layer.set_cell((10, 10), 1) - - def test_modify_cell_out_of_bounds(self): - with self.assertRaises(IndexError): - self.layer.modify_cell((10, 10), lambda x: x + 5) - - # Edge Case: Selecting Cells with Complex Conditions - def test_select_cells_complex_condition(self): - self.layer.data = np.random.rand(10, 10) - selected = self.layer.select_cells(lambda x: (x > 0.5) & (x < 0.75)) - for c in selected: - self.assertTrue(0.5 < self.layer.data[c] < 0.75) - - # More edge cases - def test_set_cells_with_numpy_ufunc(self): - # Set some cells to a specific value - self.layer.data[0:5, 0:5] = 5 - - # Use a numpy ufunc as a condition. Here, we're using `np.greater` - # which will return True for cells with values greater than 2. - condition = np.greater - self.layer.set_cells(10, lambda x: condition(x, 2)) - - # Check if cells that had value greater than 2 are now set to 10 - updated_cells = self.layer.data[0:5, 0:5] - np.testing.assert_array_equal(updated_cells, np.full((5, 5), 10)) - - # Check if cells that had value 0 (less than or equal to 2) remain unchanged - unchanged_cells = self.layer.data[5:, 5:] - np.testing.assert_array_equal(unchanged_cells, np.zeros((5, 5))) - - def test_modify_cell_boundary_condition(self): - self.layer.data = np.zeros((10, 10)) - self.layer.modify_cell((0, 0), lambda x: x + 5) - self.layer.modify_cell((9, 9), lambda x: x + 5) - self.assertEqual(self.layer.data[0, 0], 5) - self.assertEqual(self.layer.data[9, 9], 5) - - def test_aggregate_property_std_dev(self): - self.layer.data = np.arange(100).reshape(10, 10) - result = self.layer.aggregate_property(np.std) - self.assertAlmostEqual(result, np.std(np.arange(100)), places=5) - - def test_data_type_consistency(self): - self.layer.data = np.zeros((10, 10), dtype=int) - self.layer.set_cell((5, 5), 5.5) - self.assertIsInstance(self.layer.data[5, 5], self.layer.data.dtype.type) - - -class TestSingleGrid(unittest.TestCase): - def setUp(self): - self.space = SingleGrid(50, 50, False) - self.agents = [] - for i, pos in enumerate(TEST_AGENTS_GRID): - a = MockAgent(i) - self.agents.append(a) - self.space.place_agent(a, pos) - - def test_agent_positions(self): - """ - Ensure that the agents are all placed properly. - """ - for i, pos in enumerate(TEST_AGENTS_GRID): - a = self.agents[i] - assert a.pos == pos - - def test_remove_agent(self): - for i, pos in enumerate(TEST_AGENTS_GRID): - a = self.agents[i] - assert a.pos == pos - assert self.space[pos[0]][pos[1]] == a - self.space.remove_agent(a) - assert a.pos is None - assert self.space[pos[0]][pos[1]] is None - - def test_empty_cells(self): - if self.space.exists_empty_cells(): - for i, pos in enumerate(list(self.space.empties)): - a = MockAgent(-i) - self.space.place_agent(a, pos) - with self.assertRaises(Exception): - self.space.move_to_empty(a) - - def test_empty_mask_consistency(self): - # Check that the empty mask is consistent with the empties set - empty_mask = self.space.empty_mask - empties = self.space.empties - for i in range(self.space.width): - for j in range(self.space.height): - mask_value = empty_mask[i, j] - empties_value = (i, j) in empties - assert mask_value == empties_value - - def move_agent(self): - agent_number = 0 - initial_pos = TEST_AGENTS_GRID[agent_number] - final_pos = (7, 7) - - _agent = self.agents[agent_number] - - assert _agent.pos == initial_pos - assert self.space[initial_pos[0]][initial_pos[1]] == _agent - assert self.space[final_pos[0]][final_pos[1]] is None - self.space.move_agent(_agent, final_pos) - assert _agent.pos == final_pos - assert self.space[initial_pos[0]][initial_pos[1]] is None - assert self.space[final_pos[0]][final_pos[1]] == _agent - - def test_move_agent_random_selection(self): - agent = self.agents[0] - possible_positions = [(10, 10), (20, 20), (30, 30)] - self.space.move_agent_to_one_of(agent, possible_positions, selection="random") - assert agent.pos in possible_positions - - def test_move_agent_closest_selection(self): - agent = self.agents[0] - agent.pos = (5, 5) - possible_positions = [(6, 6), (10, 10), (20, 20)] - self.space.move_agent_to_one_of(agent, possible_positions, selection="closest") - assert agent.pos == (6, 6) - - def test_move_agent_invalid_selection(self): - agent = self.agents[0] - possible_positions = [(10, 10), (20, 20), (30, 30)] - with self.assertRaises(ValueError): - self.space.move_agent_to_one_of( - agent, possible_positions, selection="invalid_option" - ) - - def test_distance_squared(self): - pos1 = (3, 4) - pos2 = (0, 0) - expected_distance_squared = 3**2 + 4**2 - assert self.space._distance_squared(pos1, pos2) == expected_distance_squared - - def test_iter_cell_list_contents(self): - """ - Test neighborhood retrieval - """ - cell_list_1 = list(self.space.iter_cell_list_contents(TEST_AGENTS_GRID[0])) - assert len(cell_list_1) == 1 - - cell_list_2 = list( - self.space.iter_cell_list_contents( - (TEST_AGENTS_GRID[0], TEST_AGENTS_GRID[1]) - ) - ) - assert len(cell_list_2) == 2 - - cell_list_3 = list(self.space.iter_cell_list_contents(tuple(TEST_AGENTS_GRID))) - assert len(cell_list_3) == 3 - - cell_list_4 = list( - self.space.iter_cell_list_contents((TEST_AGENTS_GRID[0], (0, 0))) - ) - assert len(cell_list_4) == 1 - - -class TestSingleGridTorus(unittest.TestCase): - def setUp(self): - self.space = SingleGrid(50, 50, True) # Torus is True here - self.agents = [] - for i, pos in enumerate(TEST_AGENTS_GRID): - a = MockAgent(i) - self.agents.append(a) - self.space.place_agent(a, pos) - - def test_move_agent_random_selection(self): - agent = self.agents[0] - possible_positions = [(49, 49), (1, 1), (25, 25)] - self.space.move_agent_to_one_of(agent, possible_positions, selection="random") - assert agent.pos in possible_positions - - def test_move_agent_closest_selection(self): - agent = self.agents[0] - agent.pos = (0, 0) - possible_positions = [(3, 3), (49, 49), (25, 25)] - self.space.move_agent_to_one_of(agent, possible_positions, selection="closest") - # Expecting (49, 49) to be the closest in a torus grid - assert agent.pos == (49, 49) - - def test_move_agent_invalid_selection(self): - agent = self.agents[0] - possible_positions = [(10, 10), (20, 20), (30, 30)] - with self.assertRaises(ValueError): - self.space.move_agent_to_one_of( - agent, possible_positions, selection="invalid_option" - ) - - def test_move_agent_empty_list(self): - agent = self.agents[0] - possible_positions = [] - agent.pos = (3, 3) - self.space.move_agent_to_one_of(agent, possible_positions, selection="random") - assert agent.pos == (3, 3) - - def test_move_agent_empty_list_warning(self): - agent = self.agents[0] - possible_positions = [] - # Should assert RuntimeWarning - with self.assertWarns(RuntimeWarning): - self.space.move_agent_to_one_of( - agent, possible_positions, selection="random", handle_empty="warning" - ) - - def test_move_agent_empty_list_error(self): - agent = self.agents[0] - possible_positions = [] - with self.assertRaises(ValueError): - self.space.move_agent_to_one_of( - agent, possible_positions, selection="random", handle_empty="error" - ) - - def test_distance_squared_torus(self): - pos1 = (0, 0) - pos2 = (49, 49) - expected_distance_squared = 1**2 + 1**2 # In torus, these points are close - assert self.space._distance_squared(pos1, pos2) == expected_distance_squared - - -class TestSingleGridWithPropertyGrid(unittest.TestCase): - def setUp(self): - self.grid = SingleGrid(10, 10, False) - self.property_layer1 = PropertyLayer("layer1", 10, 10, 0, dtype=int) - self.property_layer2 = PropertyLayer("layer2", 10, 10, 1.0, dtype=float) - self.grid.add_property_layer(self.property_layer1) - self.grid.add_property_layer(self.property_layer2) - - # Test adding and removing property layers - def test_add_property_layer(self): - self.assertIn("layer1", self.grid.properties) - self.assertIn("layer2", self.grid.properties) - - def test_remove_property_layer(self): - self.grid.remove_property_layer("layer1") - self.assertNotIn("layer1", self.grid.properties) - - def test_add_property_layer_mismatched_dimensions(self): - with self.assertRaises(ValueError): - self.grid.add_property_layer(PropertyLayer("layer3", 5, 5, 0, dtype=int)) - - def test_add_existing_property_layer(self): - with self.assertRaises(ValueError): - self.grid.add_property_layer(self.property_layer1) - - def test_remove_nonexistent_property_layer(self): - with self.assertRaises(ValueError): - self.grid.remove_property_layer("nonexistent_layer") - - # Test getting masks - def test_get_empty_mask(self): - empty_mask = self.grid.empty_mask - self.assertTrue(np.all(empty_mask == np.ones((10, 10), dtype=bool))) - - def test_get_empty_mask_with_agent(self): - agent = MockAgent(0) - self.grid.place_agent(agent, (4, 6)) - - empty_mask = self.grid.empty_mask - expected_mask = np.ones((10, 10), dtype=bool) - expected_mask[4, 6] = False - - self.assertTrue(np.all(empty_mask == expected_mask)) - - def test_get_neighborhood_mask(self): - agent = MockAgent(0) - agent2 = MockAgent(1) - self.grid.place_agent(agent, (5, 5)) - self.grid.place_agent(agent2, (5, 6)) - neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) - expected_mask = np.zeros((10, 10), dtype=bool) - expected_mask[4:7, 4:7] = True - expected_mask[5, 5] = False - self.assertTrue(np.all(neighborhood_mask == expected_mask)) - - # Test selecting and moving to cells based on multiple conditions - def test_select_cells_by_properties(self): - def condition(x): - return x == 0 - - selected_cells = self.grid.select_cells({"layer1": condition}) - self.assertEqual(len(selected_cells), 100) - - def test_select_cells_by_properties_return_mask(self): - def condition(x): - return x == 0 - - selected_mask = self.grid.select_cells({"layer1": condition}, return_list=False) - self.assertTrue(isinstance(selected_mask, np.ndarray)) - self.assertTrue(selected_mask.all()) - - def test_move_agent_to_cell_by_properties(self): - agent = MockAgent(1) - self.grid.place_agent(agent, (5, 5)) - conditions = {"layer1": lambda x: x == 0} - target_cells = self.grid.select_cells(conditions) - self.grid.move_agent_to_one_of(agent, target_cells) - # Agent should move, since none of the cells match the condition - self.assertNotEqual(agent.pos, (5, 5)) - - def test_move_agent_no_eligible_cells(self): - agent = MockAgent(3) - self.grid.place_agent(agent, (5, 5)) - conditions = {"layer1": lambda x: x != 0} - target_cells = self.grid.select_cells(conditions) - self.grid.move_agent_to_one_of(agent, target_cells) - self.assertEqual(agent.pos, (5, 5)) - - # Test selecting and moving to cells based on extreme values - def test_select_extreme_value_cells(self): - self.grid.properties["layer2"].set_cell((3, 1), 1.1) - target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"}) - self.assertIn((3, 1), target_cells) - - def test_select_extreme_value_cells_return_mask(self): - self.grid.properties["layer2"].set_cell((3, 1), 1.1) - target_mask = self.grid.select_cells( - extreme_values={"layer2": "highest"}, return_list=False - ) - self.assertTrue(isinstance(target_mask, np.ndarray)) - self.assertTrue(target_mask[3, 1]) - - def test_move_agent_to_extreme_value_cell(self): - agent = MockAgent(2) - self.grid.place_agent(agent, (5, 5)) - self.grid.properties["layer2"].set_cell((3, 1), 1.1) - target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"}) - self.grid.move_agent_to_one_of(agent, target_cells) - self.assertEqual(agent.pos, (3, 1)) - - # Test using masks - def test_select_cells_by_properties_with_empty_mask(self): - self.grid.place_agent( - MockAgent(0), (5, 5) - ) # Placing an agent to ensure some cells are not empty - empty_mask = self.grid.empty_mask - - def condition(x): - return x == 0 - - selected_cells = self.grid.select_cells({"layer1": condition}, masks=empty_mask) - self.assertNotIn( - (5, 5), selected_cells - ) # (5, 5) should not be in the selection as it's not empty - - def test_select_cells_by_properties_with_neighborhood_mask(self): - neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) - - def condition(x): - return x == 0 - - selected_cells = self.grid.select_cells( - {"layer1": condition}, masks=neighborhood_mask - ) - expected_selection = [ - (4, 4), - (4, 5), - (4, 6), - (5, 4), - (5, 6), - (6, 4), - (6, 5), - (6, 6), - ] # Cells in the neighborhood of (5, 5) - self.assertCountEqual(selected_cells, expected_selection) - - def test_move_agent_to_cell_by_properties_with_empty_mask(self): - agent = MockAgent(1) - self.grid.place_agent(agent, (5, 5)) - self.grid.place_agent( - MockAgent(2), (4, 5) - ) # Placing another agent to create a non-empty cell - empty_mask = self.grid.empty_mask - conditions = {"layer1": lambda x: x == 0} - target_cells = self.grid.select_cells(conditions, masks=empty_mask) - self.grid.move_agent_to_one_of(agent, target_cells) - self.assertNotEqual( - agent.pos, (4, 5) - ) # Agent should not move to (4, 5) as it's not empty - - def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self): - agent = MockAgent(1) - self.grid.place_agent(agent, (5, 5)) - neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) - conditions = {"layer1": lambda x: x == 0} - target_cells = self.grid.select_cells(conditions, masks=neighborhood_mask) - self.grid.move_agent_to_one_of(agent, target_cells) - self.assertIn( - agent.pos, [(4, 4), (4, 5), (4, 6), (5, 4), (5, 6), (6, 4), (6, 5), (6, 6)] - ) # Agent should move within the neighborhood - - # Test invalid inputs - def test_invalid_property_name_in_conditions(self): - def condition(x): - return x == 0 - - with self.assertRaises(KeyError): - self.grid.select_cells(conditions={"nonexistent_layer": condition}) - - # Test if coordinates means the same between the grid and the property layer - def test_property_layer_coordinates(self): - agent = MockAgent(0) - correct_pos = (1, 8) - incorrect_pos = (8, 1) - self.grid.place_agent(agent, correct_pos) - - # Simple check on layer 1: set by agent, check by layer - self.grid.properties["layer1"].set_cell(agent.pos, 2) - self.assertEqual(self.grid.properties["layer1"].data[agent.pos], 2) - - # More complicated check on layer 2: set by layer, check by agent - self.grid.properties["layer2"].set_cell(correct_pos, 3) - self.grid.properties["layer2"].set_cell(incorrect_pos, 4) - - correct_grid_value = self.grid.properties["layer2"].data[correct_pos] - incorrect_grid_value = self.grid.properties["layer2"].data[incorrect_pos] - agent_grid_value = self.grid.properties["layer2"].data[agent.pos] - - self.assertEqual(correct_grid_value, agent_grid_value) - self.assertNotEqual(incorrect_grid_value, agent_grid_value) - - # Test selecting cells with only_empty parameter - def test_select_cells_only_empty(self): - self.grid.place_agent(MockAgent(0), (5, 5)) # Occupying a cell - selected_cells = self.grid.select_cells(only_empty=True) - self.assertNotIn( - (5, 5), selected_cells - ) # The occupied cell should not be selected - - def test_select_cells_only_empty_with_conditions(self): - self.grid.place_agent(MockAgent(1), (5, 5)) - self.grid.properties["layer1"].set_cell((5, 5), 2) - self.grid.properties["layer1"].set_cell((6, 6), 2) - - def condition(x): - return x == 2 - - selected_cells = self.grid.select_cells({"layer1": condition}, only_empty=True) - self.assertIn((6, 6), selected_cells) - self.assertNotIn((5, 5), selected_cells) - - # Test selecting cells with multiple extreme values - def test_select_cells_multiple_extreme_values(self): - self.grid.properties["layer1"].set_cell((1, 1), 3) - self.grid.properties["layer1"].set_cell((2, 2), 3) - self.grid.properties["layer2"].set_cell((2, 2), 0.5) - self.grid.properties["layer2"].set_cell((3, 3), 0.5) - selected_cells = self.grid.select_cells( - extreme_values={"layer1": "highest", "layer2": "lowest"} - ) - self.assertIn((2, 2), selected_cells) - self.assertNotIn((1, 1), selected_cells) - self.assertNotIn((3, 3), selected_cells) - self.assertEqual(len(selected_cells), 1) - - -class TestSingleNetworkGrid(unittest.TestCase): - GRAPH_SIZE = 10 - - def setUp(self): - """ - Create a test network grid and populate with Mock Agents. - """ - G = nx.cycle_graph(TestSingleNetworkGrid.GRAPH_SIZE) # noqa: N806 - self.space = NetworkGrid(G) - self.agents = [] - for i, pos in enumerate(TEST_AGENTS_NETWORK_SINGLE): - a = MockAgent(i) - self.agents.append(a) - self.space.place_agent(a, pos) - - def test_agent_positions(self): - """ - Ensure that the agents are all placed properly. - """ - for i, pos in enumerate(TEST_AGENTS_NETWORK_SINGLE): - a = self.agents[i] - assert a.pos == pos - - def test_get_neighborhood(self): - assert len(self.space.get_neighborhood(0, include_center=True)) == 3 - assert len(self.space.get_neighborhood(0, include_center=False)) == 2 - assert len(self.space.get_neighborhood(2, include_center=True, radius=3)) == 7 - assert len(self.space.get_neighborhood(2, include_center=False, radius=3)) == 6 - - def test_get_neighbors(self): - """ - Test the get_neighbors method with varying radius and include_center values. Note there are agents on node 0, 1 and 5. - """ - # Test with default radius (1) and include_center = False - neighbors_default = self.space.get_neighbors(0, include_center=False) - self.assertEqual( - len(neighbors_default), - 1, - "Should have 1 neighbors with default radius and exclude center", - ) - - # Test with default radius (1) and include_center = True - neighbors_include_center = self.space.get_neighbors(0, include_center=True) - self.assertEqual( - len(neighbors_include_center), - 2, - "Should have 2 neighbors (including center) with default radius", - ) - - # Test with radius = 2 and include_center = False - neighbors_radius_2 = self.space.get_neighbors(0, include_center=False, radius=5) - expected_count_radius_2 = 2 - self.assertEqual( - len(neighbors_radius_2), - expected_count_radius_2, - f"Should have {expected_count_radius_2} neighbors with radius 2 and exclude center", - ) - - # Test with radius = 2 and include_center = True - neighbors_radius_2_include_center = self.space.get_neighbors( - 0, include_center=True, radius=5 - ) - expected_count_radius_2_include_center = ( - 3 # Adjust this based on your network structure - ) - self.assertEqual( - len(neighbors_radius_2_include_center), - expected_count_radius_2_include_center, - f"Should have {expected_count_radius_2_include_center} neighbors (including center) with radius 2", - ) - - def test_move_agent(self): - initial_pos = 1 - agent_number = 1 - final_pos = TestSingleNetworkGrid.GRAPH_SIZE - 1 - - _agent = self.agents[agent_number] - - assert _agent.pos == initial_pos - assert _agent in self.space.G.nodes[initial_pos]["agent"] - assert _agent not in self.space.G.nodes[final_pos]["agent"] - self.space.move_agent(_agent, final_pos) - assert _agent.pos == final_pos - assert _agent not in self.space.G.nodes[initial_pos]["agent"] - assert _agent in self.space.G.nodes[final_pos]["agent"] - - def test_remove_agent(self): - for i, pos in enumerate(TEST_AGENTS_NETWORK_SINGLE): - a = self.agents[i] - assert a.pos == pos - assert a in self.space.G.nodes[pos]["agent"] - self.space.remove_agent(a) - assert a.pos is None - assert a not in self.space.G.nodes[pos]["agent"] - - def test_is_cell_empty(self): - assert not self.space.is_cell_empty(0) - assert self.space.is_cell_empty(TestSingleNetworkGrid.GRAPH_SIZE - 1) - - def test_get_cell_list_contents(self): - assert self.space.get_cell_list_contents([0]) == [self.agents[0]] - assert self.space.get_cell_list_contents( - list(range(TestSingleNetworkGrid.GRAPH_SIZE)) - ) == [self.agents[0], self.agents[1], self.agents[2]] - - def test_get_all_cell_contents(self): - assert self.space.get_all_cell_contents() == [ - self.agents[0], - self.agents[1], - self.agents[2], - ] - - -class TestMultipleNetworkGrid(unittest.TestCase): - GRAPH_SIZE = 3 - - def setUp(self): - """ - Create a test network grid and populate with Mock Agents. - """ - G = nx.complete_graph(TestMultipleNetworkGrid.GRAPH_SIZE) # noqa: N806 - self.space = NetworkGrid(G) - self.agents = [] - for i, pos in enumerate(TEST_AGENTS_NETWORK_MULTIPLE): - a = MockAgent(i) - self.agents.append(a) - self.space.place_agent(a, pos) - - def test_agent_positions(self): - """ - Ensure that the agents are all placed properly. - """ - for i, pos in enumerate(TEST_AGENTS_NETWORK_MULTIPLE): - a = self.agents[i] - assert a.pos == pos - - def test_get_neighbors(self): - assert ( - len(self.space.get_neighborhood(0, include_center=True)) - == TestMultipleNetworkGrid.GRAPH_SIZE - ) - assert ( - len(self.space.get_neighborhood(0, include_center=False)) - == TestMultipleNetworkGrid.GRAPH_SIZE - 1 - ) - - def test_move_agent(self): - initial_pos = 1 - agent_number = 1 - final_pos = 0 - - _agent = self.agents[agent_number] - - assert _agent.pos == initial_pos - assert _agent in self.space.G.nodes[initial_pos]["agent"] - assert _agent not in self.space.G.nodes[final_pos]["agent"] - assert len(self.space.G.nodes[initial_pos]["agent"]) == 2 - assert len(self.space.G.nodes[final_pos]["agent"]) == 1 - - self.space.move_agent(_agent, final_pos) - - assert _agent.pos == final_pos - assert _agent not in self.space.G.nodes[initial_pos]["agent"] - assert _agent in self.space.G.nodes[final_pos]["agent"] - assert len(self.space.G.nodes[initial_pos]["agent"]) == 1 - assert len(self.space.G.nodes[final_pos]["agent"]) == 2 - - def test_is_cell_empty(self): - assert not self.space.is_cell_empty(0) - assert not self.space.is_cell_empty(1) - assert self.space.is_cell_empty(2) - - def test_get_cell_list_contents(self): - assert self.space.get_cell_list_contents([0]) == [self.agents[0]] - assert self.space.get_cell_list_contents([1]) == [ - self.agents[1], - self.agents[2], - ] - assert self.space.get_cell_list_contents( - list(range(TestMultipleNetworkGrid.GRAPH_SIZE)) - ) == [self.agents[0], self.agents[1], self.agents[2]] - - def test_get_all_cell_contents(self): - assert self.space.get_all_cell_contents() == [ - self.agents[0], - self.agents[1], - self.agents[2], - ] - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_time.py b/tests/test_time.py deleted file mode 100644 index a9d8e2e..0000000 --- a/tests/test_time.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -Test the advanced schedulers. -""" - -import unittest -from unittest import TestCase, mock - -from mesa import Agent, Model -from mesa.time import ( - BaseScheduler, - RandomActivation, - RandomActivationByType, - SimultaneousActivation, - StagedActivation, -) - -RANDOM = "random" -STAGED = "staged" -SIMULTANEOUS = "simultaneous" -RANDOM_BY_TYPE = "random_by_type" - - -class MockAgent(Agent): - """ - Minimalistic agent for testing purposes. - """ - - def __init__(self, unique_id, model): - super().__init__(unique_id, model) - self.steps = 0 - self.advances = 0 - - def kill_other_agent(self): - for agent in self.model.schedule.agents: - if agent is not self: - agent.remove() - - def stage_one(self): - if self.model.enable_kill_other_agent: - self.kill_other_agent() - self.model.log.append(self.unique_id + "_1") - - def stage_two(self): - self.model.log.append(self.unique_id + "_2") - - def advance(self): - self.advances += 1 - - def step(self): - if self.model.enable_kill_other_agent: - self.kill_other_agent() - self.steps += 1 - self.model.log.append(self.unique_id) - - -class MockModel(Model): - def __init__(self, shuffle=False, activation=STAGED, enable_kill_other_agent=False): - """ - Creates a Model instance with a schedule - - Args: - shuffle (Bool): whether or not to instantiate a scheduler - with shuffling. - This option is only used for - StagedActivation schedulers. - - activation (str): which kind of scheduler to use. - 'random' creates a RandomActivation scheduler. - 'staged' creates a StagedActivation scheduler. - The default scheduler is a BaseScheduler. - """ - super().__init__() - self.log = [] - self.enable_kill_other_agent = enable_kill_other_agent - - # Make scheduler - if activation == STAGED: - model_stages = ["stage_one", "model.model_stage", "stage_two"] - self.schedule = StagedActivation( - self, stage_list=model_stages, shuffle=shuffle - ) - elif activation == RANDOM: - self.schedule = RandomActivation(self) - elif activation == SIMULTANEOUS: - self.schedule = SimultaneousActivation(self) - elif activation == RANDOM_BY_TYPE: - self.schedule = RandomActivationByType(self) - else: - self.schedule = BaseScheduler(self) - - # Make agents - for name in ["A", "B"]: - agent = MockAgent(name, self) - self.schedule.add(agent) - - def step(self): - self.schedule.step() - - def model_stage(self): - self.log.append("model_stage") - - -class TestStagedActivation(TestCase): - """ - Test the staged activation. - """ - - expected_output = ["A_1", "B_1", "model_stage", "A_2", "B_2"] - - def test_no_shuffle(self): - """ - Testing the staged activation without shuffling. - """ - model = MockModel(shuffle=False) - model.step() - model.step() - assert all(i == j for i, j in zip(model.log[:5], model.log[5:])) - - def test_shuffle(self): - """ - Test the staged activation with shuffling - """ - model = MockModel(shuffle=True) - model.step() - for output in self.expected_output[:2]: - assert output in model.log[:2] - for output in self.expected_output[3:]: - assert output in model.log[3:] - assert self.expected_output[2] == model.log[2] - - def test_shuffle_shuffles_agents(self): - model = MockModel(shuffle=True) - model.random = mock.Mock() - assert model.random.shuffle.call_count == 0 - model.step() - assert model.random.shuffle.call_count == 1 - - def test_remove(self): - """ - Test the staged activation can remove an agent - """ - model = MockModel(shuffle=True) - agents = list(model.schedule._agents) - agent = agents[0] - model.schedule.remove(agents[0]) - assert agent not in model.schedule.agents - - def test_intrastep_remove(self): - """ - Test the staged activation can remove an agent in a - step of another agent so that the one removed doesn't step. - """ - model = MockModel(shuffle=True, enable_kill_other_agent=True) - model.step() - assert len(model.log) == 3 - - def test_add_existing_agent(self): - model = MockModel() - agent = model.schedule.agents[0] - with self.assertRaises(Exception): - model.schedule.add(agent) - - -class TestRandomActivation(TestCase): - """ - Test the random activation. - """ - - def test_init(self): - model = Model() - agents = [MockAgent(model.next_id(), model) for _ in range(10)] - - scheduler = RandomActivation(model, agents) - assert all(agent in scheduler.agents for agent in agents) - - def test_random_activation_step_shuffles(self): - """ - Test the random activation step - """ - model = MockModel(activation=RANDOM) - model.random = mock.Mock() - model.schedule.step() - assert model.random.shuffle.call_count == 1 - - def test_random_activation_step_increments_step_and_time_counts(self): - """ - Test the random activation step increments step and time counts - """ - model = MockModel(activation=RANDOM) - assert model.schedule.steps == 0 - assert model.schedule.time == 0 - model.schedule.step() - assert model.schedule.steps == 1 - assert model.schedule.time == 1 - - def test_random_activation_step_steps_each_agent(self): - """ - Test the random activation step causes each agent to step - """ - model = MockModel(activation=RANDOM) - model.step() - agent_steps = [i.steps for i in model.schedule.agents] - # one step for each of 2 agents - assert all(x == 1 for x in agent_steps) - - def test_intrastep_remove(self): - """ - Test the random activation can remove an agent in a - step of another agent so that the one removed doesn't step. - """ - model = MockModel(activation=RANDOM, enable_kill_other_agent=True) - model.step() - assert len(model.log) == 1 - - def test_get_agent_keys(self): - model = MockModel(activation=RANDOM) - - keys = model.schedule.get_agent_keys() - agent_ids = [agent.unique_id for agent in model.agents] - assert all(entry_i == entry_j for entry_i, entry_j in zip(keys, agent_ids)) - - keys = model.schedule.get_agent_keys(shuffle=True) - agent_ids = {agent.unique_id for agent in model.agents} - assert all(entry in agent_ids for entry in keys) - - def test_not_sequential(self): - model = MockModel(activation=RANDOM) - # Create 10 agents - for _ in range(10): - model.schedule.add(MockAgent(model.next_id(), model)) - # Run 3 steps - for _ in range(3): - model.step() - # Filter out non-integer elements from the log - filtered_log = [item for item in model.log if isinstance(item, int)] - - # Check that there are no 18 consecutive agents id's in the filtered log - total_agents = 10 - assert not any( - all( - (filtered_log[(i + j) % total_agents] - filtered_log[i]) % total_agents - == j % total_agents - for j in range(18) - ) - for i in range(len(filtered_log)) - ), f"Agents are activated sequentially:\n{filtered_log}" - - -class TestSimultaneousActivation(TestCase): - """ - Test the simultaneous activation. - """ - - def test_simultaneous_activation_step_steps_and_advances_each_agent(self): - """ - Test the simultaneous activation step causes each agent to step - """ - model = MockModel(activation=SIMULTANEOUS) - model.step() - # one step for each of 2 agents - agent_steps = [i.steps for i in model.schedule.agents] - agent_advances = [i.advances for i in model.schedule.agents] - assert all(x == 1 for x in agent_steps) - assert all(x == 1 for x in agent_advances) - - -class TestRandomActivationByType(TestCase): - """ - Test the random activation by type. - TODO implement at least 2 types of agents, and test that step_type only - does step for one type of agents, not the entire agents. - """ - - def test_init(self): - model = Model() - agents = [MockAgent(model.next_id(), model) for _ in range(10)] - agents += [Agent(model.next_id(), model) for _ in range(10)] - - scheduler = RandomActivationByType(model, agents) - assert all(agent in scheduler.agents for agent in agents) - - def test_random_activation_step_shuffles(self): - """ - Test the random activation by type step - """ - model = MockModel(activation=RANDOM_BY_TYPE) - model.random = mock.Mock() - model.schedule.step() - assert model.random.shuffle.call_count == 2 - - def test_random_activation_step_increments_step_and_time_counts(self): - """ - Test the random activation by type step increments step and time counts - """ - model = MockModel(activation=RANDOM_BY_TYPE) - assert model.schedule.steps == 0 - assert model.schedule.time == 0 - model.schedule.step() - assert model.schedule.steps == 1 - assert model.schedule.time == 1 - - def test_random_activation_step_steps_each_agent(self): - """ - Test the random activation by type step causes each agent to step - """ - - model = MockModel(activation=RANDOM_BY_TYPE) - model.step() - agent_steps = [i.steps for i in model.schedule.agents] - # one step for each of 2 agents - assert all(x == 1 for x in agent_steps) - - def test_random_activation_counts(self): - """ - Test the random activation by type step causes each agent to step - """ - - model = MockModel(activation=RANDOM_BY_TYPE) - - agent_types = model.agent_types - for agent_type in agent_types: - assert model.schedule.get_type_count(agent_type) == len( - model.get_agents_of_type(agent_type) - ) - - # def test_add_non_unique_ids(self): - # """ - # Test that adding agent with duplicate ids result in an error. - # TODO: we need to run this test on all schedulers, not just - # TODO:: identical IDs is something for the agent, not the scheduler and should be tested there - # RandomActivationByType. - # """ - # model = MockModel(activation=RANDOM_BY_TYPE) - # a = MockAgent(0, model) - # b = MockAgent(0, model) - # model.schedule.add(a) - # with self.assertRaises(Exception): - # model.schedule.add(b) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_update_color_distribution.py b/tests/test_update_color_distribution.py index 4e74f9e..f144409 100644 --- a/tests/test_update_color_distribution.py +++ b/tests/test_update_color_distribution.py @@ -19,5 +19,6 @@ def test_color_distribution(self): cell.color = 1 area._update_color_distribution() new_dist = area._color_distribution - self.assertFalse(np.array_equal(old_dist, new_dist)) + # TODO: This test fails sometimes (11.09.25) + self.assertFalse(np.array_equal(old_dist, new_dist)) # <--- here self.assertAlmostEqual(np.sum(new_dist), 1.0, places=5) diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py new file mode 100644 index 0000000..fd5b349 --- /dev/null +++ b/tests/test_utility_functions.py @@ -0,0 +1,59 @@ +import unittest +import numpy as np +from src.agents.vote_agent import combine_and_normalize + +class TestUtilityFunctions(unittest.TestCase): + """Test utility functions in the src package.""" + + def test_combine_and_normalize_basic(self): + """Test basic functionality of combine_and_normalize.""" + # Test with equal arrays + arr1 = np.array([0.25, 0.25, 0.25, 0.25]) + arr2 = np.array([0.25, 0.25, 0.25, 0.25]) + result = combine_and_normalize(arr1, arr2, 0.5) + np.testing.assert_array_almost_equal(result, np.array([0.25, 0.25, 0.25, 0.25])) + + # Test with factor = 0 (should return arr2) + result = combine_and_normalize(arr1, arr2, 0.0) + np.testing.assert_array_almost_equal(result, arr2) + + # Test with factor = 1 (should return arr1) + result = combine_and_normalize(arr1, arr2, 1.0) + np.testing.assert_array_almost_equal(result, arr1) + + def test_combine_and_normalize_different_arrays(self): + """Test combine_and_normalize with different arrays.""" + arr1 = np.array([0.1, 0.2, 0.3, 0.4]) + arr2 = np.array([0.4, 0.3, 0.2, 0.1]) + + # Test with factor = 0.5 (should be average) + result = combine_and_normalize(arr1, arr2, 0.5) + expected = np.array([0.25, 0.25, 0.25, 0.25]) + np.testing.assert_array_almost_equal(result, expected) + + # Test with factor = 0.75 (weighted more toward arr1) + result = combine_and_normalize(arr1, arr2, 0.75) + expected = (0.75 * arr1 + 0.25 * arr2) / np.sum(0.75 * arr1 + 0.25 * arr2) + np.testing.assert_array_almost_equal(result, expected) + + def test_combine_and_normalize_normalization(self): + """Test that the result is properly normalized.""" + arr1 = np.array([1.0, 2.0, 3.0, 4.0]) # Not normalized + arr2 = np.array([5.0, 6.0, 7.0, 8.0]) # Not normalized + + result = combine_and_normalize(arr1, arr2, 0.5) + self.assertAlmostEqual(np.sum(result), 1.0) + + def test_combine_and_normalize_invalid_factor(self): + """Test that an invalid factor raises a ValueError.""" + arr1 = np.array([0.25, 0.25, 0.25, 0.25]) + arr2 = np.array([0.25, 0.25, 0.25, 0.25]) + + with self.assertRaises(ValueError): + combine_and_normalize(arr1, arr2, -0.1) + + with self.assertRaises(ValueError): + combine_and_normalize(arr1, arr2, 1.1) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file