From e988b94cca1dc7c69cefce84865aad3b06357175 Mon Sep 17 00:00:00 2001 From: jurikane Date: Fri, 23 May 2025 16:41:49 +0800 Subject: [PATCH 1/5] in the middle of implementing headless run with replay --- .gitignore | 5 + democracy_sim/app.py | 86 ---------- democracy_sim/config/config.yaml | 46 +++++ democracy_sim/headless_replay_README.md | 101 +++++++++++ democracy_sim/model_setup.py | 8 +- democracy_sim/participation_agent.py | 5 +- democracy_sim/participation_model.py | 30 +++- democracy_sim/replay_stubs.py | 18 ++ democracy_sim/run_headless.py | 128 ++++++++++++++ democracy_sim/run_replay.py | 180 ++++++++++++++++++++ docs/technical/agents.md | 41 +++++ docs/technical/architecture_overview.md | 130 ++++++++++++++ docs/technical/areas.md | 32 ++++ docs/technical/installation_instructions.md | 1 + mkdocs.yml | 4 + np_performance_test_1.py | 15 -- np_performance_test_2.py | 64 ------- tests/test_utility_functions.py | 59 +++++++ 18 files changed, 777 insertions(+), 176 deletions(-) delete mode 100644 democracy_sim/app.py create mode 100644 democracy_sim/config/config.yaml create mode 100644 democracy_sim/headless_replay_README.md create mode 100644 democracy_sim/replay_stubs.py create mode 100644 democracy_sim/run_headless.py create mode 100644 democracy_sim/run_replay.py create mode 100644 docs/technical/agents.md create mode 100644 docs/technical/architecture_overview.md create mode 100644 docs/technical/areas.md create mode 100644 docs/technical/installation_instructions.md delete mode 100644 np_performance_test_1.py delete mode 100644 np_performance_test_2.py create mode 100644 tests/test_utility_functions.py diff --git a/.gitignore b/.gitignore index 3b9f4f3..6a5ecb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ /.idea .DS_Store +.junie +TODO.txt __pycache__/ *.ipynb +/democracy_sym/config/* +/democracy_sym/simulation_output/* /examples /starter_model /mesa @@ -22,3 +26,4 @@ templates *.cache ai_info.txt convert_docstrings.py +simulation_output/ \ No newline at end of file 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/config/config.yaml b/democracy_sim/config/config.yaml new file mode 100644 index 0000000..41f4a94 --- /dev/null +++ b/democracy_sim/config/config.yaml @@ -0,0 +1,46 @@ +# config.yaml +model: + # Elections + election_costs: 1 + max_reward: 50 + 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) + + draw_borders: true + + # Voting Areas + num_areas: 16 + av_area_height: 25 + av_area_width: 20 + area_size_variance: 0.0 + + # Statistics and Views + show_area_stats: true + +simulation: + runs: 2 # number of independent seeds + num_steps: 100 + 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 + +output: + directory: "simulation_output" \ No newline at end of file diff --git a/democracy_sim/headless_replay_README.md b/democracy_sim/headless_replay_README.md new file mode 100644 index 0000000..fbbdc41 --- /dev/null +++ b/democracy_sim/headless_replay_README.md @@ -0,0 +1,101 @@ +# Headless Simulation and Replay Guide for DemocracySim + +This guide explains how to run DemocracySim simulations in headless mode (without visualization) +and then replay them using Mesa's visualization tools. + +## Overview + +DemocracySim supports two main modes of operation: + +1. **Interactive Mode**: Run the simulation with real-time visualization using `run.py` +2. **Headless Mode**: Run the simulation without visualization using `run_headless.py`, saving the results to CSV files +3. **Replay Mode**: Visualize previously saved simulation data using `run_replay.py` + +This workflow allows you to: +- Run computationally intensive simulations without the overhead of visualization +- Save simulation results for later analysis +- Replay simulations to visualize the dynamics and outcomes + +## Running a Headless Simulation + +To run a simulation in headless mode: + +```bash +cd democracy_sim +python run_headless.py +``` + +By default, this will: +1. Load configuration from `config/config.yaml` +2. Run the simulation for the specified number of steps +3. Save the results to CSV files in the `simulation_output` directory + +### Command-line Options + +You can specify a custom configuration file: + +```bash +python run_headless.py --config path/to/your/config.yaml +``` + +### Output Files + +The headless simulation produces two main output files: + +1. `model_data.csv`: Contains model-level data for each step, including: + - Collective assets + - Gini Index + - Voter turnout + - Color distributions + - Grid state (as a serialized list of lists) + +2. `agent_data.csv`: Contains agent-level data for each step + +## Replaying a Simulation + +To replay a previously saved simulation: + +```bash +cd democracy_sim +python run_replay.py +``` + +This will: +1. Load the simulation data from `simulation_output/model_data.csv` +2. Launch a Mesa server with the same visualization elements as the interactive mode +3. Allow you to step through the simulation or play it automatically + +The replay will look identical to running the simulation via `run.py`, but instead of computing the simulation dynamics in real-time, it's loading the pre-computed state from the CSV file. + +### How Replay Works + +The replay functionality works by: +1. Loading the fixed parameters from `config/config.yaml` +2. Loading the dynamic state data from `model_data.csv` +3. Creating a special `ReplayParticipationModel` that inherits from the regular `ParticipationModel` +4. Overriding the `step()` method to update the model state from the saved data instead of computing it +5. Using the same visualization elements as the interactive mode + +## Troubleshooting + +If you encounter issues with the replay: + +1. **Missing GridColors column**: Ensure your `model_data.csv` file includes a `GridColors` column. This column contains the serialized grid state needed for visualization. + +2. **Visualization differences**: If the replay looks different from the interactive mode, check that your visualization elements are compatible with the replay model. + +3. **File not found errors**: Make sure the paths to the CSV files are correct. By default, they should be in the `simulation_output` directory. + +## Advanced Usage + +### Custom Data Collection + +If you want to collect additional data during the headless simulation, you can modify the `initialize_datacollector` method in `participation_model.py` to include additional model reporters or agent reporters. + +### Custom Replay Visualization + +If you want to customize the replay visualization, you can modify `run_replay.py` to include different visualization elements or to change the appearance of the existing elements. + +### Parameter Sweeps + +For running multiple simulations with different parameters, consider using `parameter_sweep.py` which can run multiple headless simulations and analyze the results. \ No newline at end of file diff --git a/democracy_sim/model_setup.py b/democracy_sim/model_setup.py index 29348d1..5b175e5 100644 --- a/democracy_sim/model_setup.py +++ b/democracy_sim/model_setup.py @@ -3,10 +3,10 @@ """ 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 participation_agent import ColorCell +from participation_model import (ParticipationModel, + distance_functions, + social_welfare_functions) from math import factorial import mesa diff --git a/democracy_sim/participation_agent.py b/democracy_sim/participation_agent.py index adae33c..caa36c4 100644 --- a/democracy_sim/participation_agent.py +++ b/democracy_sim/participation_agent.py @@ -38,7 +38,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, pos, personality=None, + personality_idx=None, assets=1, add=True): """ Create a new agent. Attributes: @@ -46,6 +47,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 +60,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 diff --git a/democracy_sim/participation_model.py b/democracy_sim/participation_model.py index 8555dc5..d360f3d 100644 --- a/democracy_sim/participation_model.py +++ b/democracy_sim/participation_model.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING, cast, List, Optional 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 participation_agent import VoteAgent, ColorCell +from social_welfare_functions import majority_rule, approval_voting +from distance_functions import spearman, kendall_tau from itertools import permutations, product, combinations from math import sqrt import numpy as np @@ -343,6 +343,21 @@ def compute_collective_assets(model): sum_assets = sum(agent.assets for agent in model.voting_agents) return sum_assets +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_gini_index(model): # TODO: separate to be able to calculate it zone-wise as well as globally @@ -644,10 +659,12 @@ def initialize_voting_agents(self): # 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 + personality_idx = rng.choice(len(self.personalities), 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?! + personality_idx, 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 cell = self.grid.get_cell_list_contents([(x, y)])[0] @@ -801,7 +818,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, diff --git a/democracy_sim/replay_stubs.py b/democracy_sim/replay_stubs.py new file mode 100644 index 0000000..b69c658 --- /dev/null +++ b/democracy_sim/replay_stubs.py @@ -0,0 +1,18 @@ +import mesa + + +class StubVoteAgent(mesa.Agent): + """Only the fields the browser portrayal uses.""" + def __init__(self, uid, model, pos, personality_idx): + super().__init__(uid, model) + self.pos = pos + self.personality_idx = personality_idx # or what you draw + + +class StubArea(mesa.Agent): + """Drawn as a rectangle – lives once in the south-west corner cell.""" + def __init__(self, uid, model, pos, h, w): + super().__init__(uid, model) + self.pos = pos + self.height = h + self.width = w diff --git a/democracy_sim/run_headless.py b/democracy_sim/run_headless.py new file mode 100644 index 0000000..884eef1 --- /dev/null +++ b/democracy_sim/run_headless.py @@ -0,0 +1,128 @@ +# fast_batch.py ── headless, parallel & compact +import yaml, argparse, multiprocessing as mp, json +from datetime import datetime +from pathlib import Path +import numpy as np +import pandas as pd +import pyarrow as pa, pyarrow.parquet as pq +from participation_model import ParticipationModel +from tqdm import tqdm + +# ─────────────────────────────── CONFIG ──────────────────────────────── +def load_yaml(path): + with open(path, "r") as f: + return yaml.safe_load(f) + +def _snapshot(model): + """ + Return an H×W uint8 array with the current grid colours. + Uses the pre-allocated model.color_cells list, so no grid + access is needed. + """ + return np.fromiter( + (cell.color for cell in model.color_cells), + dtype=np.uint8, + count=model.height * model.width + ).reshape(model.height, model.width) + + +# ─────────────────────── SINGLE-RUN EXECUTION ───────────────────────── + +def run_once(run_id, model_cfg, num_steps, store_grid, grid_interval, out_dir): + np.random.seed(run_id) + model = ParticipationModel(**model_cfg) + + # ——— dump static geometry ———————————————————————————— + # agents: (unique_id, x, y, personality_idx) + agent_arr = np.array( + [(a.unique_id, a.row, a.col, a.personality_idx) + for a in model.voting_agents], + dtype=np.int32 + ) + + # areas: (unique_id, x, y, height, width) + # note: Area stores its dimensions in _height/_width + area_arr = np.array( + [(ar.unique_id, + ar.idx_field[0], ar.idx_field[1], + ar._height, ar._width) + for ar in model.areas], + dtype=np.int32 + ) + + np.savez_compressed(out_dir / f"static_{run_id}.npz", + agents=agent_arr, + areas=area_arr) + + # ----- grid data + + model_records = [] + grids = [] if store_grid else None + + for step in range(num_steps): + model.step() + + row = model.datacollector.get_model_vars_dataframe().iloc[-1].to_dict() + row["run_id"] = run_id + row["step"] = step + model_records.append(row) + + if store_grid and step % grid_interval == 0: + grids.append(_snapshot(model)) + + df = pd.DataFrame(model_records) + pq.write_table(pa.Table.from_pandas(df), + out_dir / f"model_{run_id}.parquet") + + if store_grid: + np.savez_compressed(out_dir / f"grid_{run_id}.npz", + grid=np.stack(grids, axis=0)) + + del model, model_records, grids + return run_id + + +# ──────────────────────── PARALLEL BATCH DRIVER ─────────────────────── + +def _run_wrapper(args): + """Top-level helper so it can be pickled by multiprocessing.""" + return run_once(*args) + +def batch_run(cfg): + sim_cfg = cfg["simulation"] + model_cfg = cfg["model"] + + num_runs = sim_cfg.get("runs", 20) + num_steps = sim_cfg.get("num_steps", 1000) + processes = sim_cfg.get("processes", mp.cpu_count()) + store_grid = sim_cfg.get("store_grid", False) + grid_interval = max(1, sim_cfg.get("grid_interval", 1)) + + out_dir = Path(cfg["output"].get("directory", "runs")) / \ + datetime.now().strftime("%Y%m%d_%H%M%S") + out_dir.mkdir(parents=True, exist_ok=True) + + with open(out_dir / "meta.json", "w") as f: + json.dump(dict(model=model_cfg, simulation=sim_cfg), f, indent=2) + + print(f"▶ Launching {num_runs} runs on {processes} processes ...") + args = [(i, model_cfg, num_steps, store_grid, grid_interval, out_dir) + for i in range(num_runs)] + + # --- no lambdas, only a top-level function ------------------------ + with mp.get_context("spawn").Pool(processes) as pool: + for _ in tqdm(pool.imap_unordered(_run_wrapper, args), total=num_runs): + pass + + print("✔ all runs finished → results in", out_dir) + + +# ──────────────────────────── CLI ENTRY ─────────────────────────────── +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Fast parallel headless batch for Democracy-Sim" + ) + parser.add_argument("--config", "-c", default="config/config.yaml", + help="YAML file with model/simulation/output blocks") + config = load_yaml(parser.parse_args().config) + batch_run(config) diff --git a/democracy_sim/run_replay.py b/democracy_sim/run_replay.py new file mode 100644 index 0000000..a331fb9 --- /dev/null +++ b/democracy_sim/run_replay.py @@ -0,0 +1,180 @@ +""" +Launch a Mesa web-server that *replays* a single batch run. + +Usage: + python replay_server.py [--run-dir ] [--run-id ] + +If no arguments are given, uses the most recent run in ./runs/ +""" + +import argparse, json, numpy as np, pyarrow.parquet as pq +from pathlib import Path +import mesa +from mesa.visualization.ModularVisualization import ModularServer +from participation_model import ColorCell, VoteAgent, Area, distance_functions +from model_setup import ( + canvas_element, + voter_turnout, + wealth_chart, + color_distribution_chart, +) +from itertools import combinations, permutations, product + + +# ──────────────────── REPLAY MODEL ────────────────────────── +class ParticipationReplay(mesa.Model): + """A lightweight wrapper that replays pre-computed states.""" + def __init__(self, run_path: Path): + self.meta = json.loads((run_path / "meta.json").read_text()) + self.model_df = pq.read_table(next(run_path.glob("model_*.parquet")) + ).to_pandas().sort_values("step") + static_file = next(run_path.glob("static_*.npz")) + static = np.load(static_file) + agents_data = static["agents"] + areas_data = static["areas"] + grid_file = next(run_path.glob("grid_*.npz"), None) + if grid_file is None: + raise FileNotFoundError("Grids were not stored for this run.") + self.grids = np.load(grid_file)["grid"] # (T, H, W) + + # restore params + p = self.meta["model"] + self.height, self.width = p["height"], p["width"] + self.schedule = mesa.time.BaseScheduler(self) + self.draw_borders = p.get("draw_borders", True) + # Add attributes needed by VoteAgent + self.known_cells = p.get("known_cells", 10) # Default value from model_setup.py + self.num_colors = p.get("num_colors", 3) # Default value from model_setup.py + + # Add more attributes needed by VoteAgent + distance_idx = p.get("distance_idx", 0) # Default to first distance function + self.distance_func = distance_functions[distance_idx] + self.options = self.create_all_options(self.num_colors) + self.color_search_pairs = list(combinations(range(0, self.num_colors), 2)) + + # build grid + cells + self.grid = mesa.space.MultiGrid(self.width, self.height, torus=False) + cells = [] + uid = 0 + for y in range(self.height): + for x in range(self.width): + cell = ColorCell(uid, self, (x, y), 0) + self.grid.place_agent(cell, (x, y)) + cells.append(cell) + uid += 1 + self._cells = cells + + # recreate agents + self.voting_agents = [] + for uid, x, y, pers_idx in agents_data: + a = VoteAgent(int(uid), self, (int(x), int(y)), + personality_idx=int(pers_idx)) + self.voting_agents.append(a) + self.grid.place_agent(a, (int(x), int(y))) + + # recreate areas + self.areas = [] + for uid, x, y, h, w in areas_data: + var = self.meta["model"]["area_size_variance"] + ar = Area(int(uid), self, int(h), int(w), var) + ar.idx_field = (int(x), int(y)) + self.areas.append(ar) + + # fake DataCollector + first_row = self.model_df.iloc[0] + reporters = {k: (lambda m, k=k: m._current_row[k]) + for k in first_row.keys() if k not in ("run_id", "step")} + self.datacollector = mesa.DataCollector(model_reporters=reporters) + self._current_row = first_row.to_dict() + self.datacollector.collect(self) + + self._t = 0 + self.running = True + + def step(self): + if self._t + 1 >= len(self.grids): + self.running = False + return + self._t += 1 + self._current_row = self.model_df.iloc[self._t].to_dict() + colours_flat = self.grids[self._t].ravel() + for i, cell in enumerate(self._cells): + cell.color = int(colours_flat[i]) + self.datacollector.collect(self) + + @staticmethod + def create_all_options(n: int, include_ties=False): + """ + Creates a matrix (an array of all possible ranking vectors), + if specified including ties. + Rank values start from 0. + + Args: + n (int): The number of items to rank (number of colors in our case) + include_ties (bool): If True, rankings include ties. + + Returns: + np.array: A matrix containing all possible ranking vectors. + """ + if include_ties: + # Create all possible combinations and sort out invalid rankings + # i.e. [1, 1, 1] or [1, 2, 2] aren't valid as no option is ranked first. + r = np.array([np.array(comb) for comb in product(range(n), repeat=n) + if set(range(max(comb))).issubset(comb)]) + else: + r = np.array([np.array(p) for p in permutations(range(n))]) + return r + + +# ─────────────────────── server launcher ────────────────────── +def launch(run_dir: str, run_id: int): + run_path = Path(run_dir) + if run_id >= 0: + run_path = run_path / f"model_{run_id}.parquet" + if run_path.exists(): + run_path = run_path.parent + else: + raise FileNotFoundError("run_id not found in that directory.") + + model_params = {"run_path": run_path} + + server = ModularServer( + ParticipationReplay, + [canvas_element, wealth_chart, + color_distribution_chart, voter_turnout], + "Democracy-Sim replay", + model_params, + ) + server.port = 8585 + server.launch() + + +# ────────────────────────── CLI ─────────────────────────────── +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--run-dir", + help="Path to a run folder (e.g. simulation_output/20250425_101530)") + ap.add_argument("--run-id", type=int, default=-1, + help="Run index to replay (model_#.parquet). Default = -1 = first one") + args = ap.parse_args() + + if args.run_dir is None: + runs_root = Path("simulation_output") + run_dirs = sorted([d for d in runs_root.iterdir() if d.is_dir()], + key=lambda p: p.name, reverse=True) + if not run_dirs: + raise FileNotFoundError( + "No run directories found in ./simulation_output/") + + print("Available simulation runs:") + for i, run in enumerate(run_dirs): + print(f" [{i}] {run.name}") + + selection = input("Select a run by number: ").strip() + if not selection.isdigit() or not (0 <= int(selection) < len(run_dirs)): + raise ValueError("Invalid selection.") + + args.run_dir = str(run_dirs[int(selection)]) + print(f"[INFO] Selected: {args.run_dir}") + + launch(**vars(args)) diff --git a/docs/technical/agents.md b/docs/technical/agents.md new file mode 100644 index 0000000..a230069 --- /dev/null +++ b/docs/technical/agents.md @@ -0,0 +1,41 @@ +# Agents + +Agents represent autonomous decision-making entities participating in grid-based simulations. +They operate under defined constraints (e.g., budgets, limited knowledge) +and use machine-learned strategies to optimize their outcomes. + +--- + +### **VoteAgent Class** + +Defined in: `participation_agent.py` + +#### **Key Attributes** +- **`unique_id`**: An identifier for the agent. +- **`personality`**: A numpy array representing the agent's preferences among colors. +- **`assets`**: Represents personal resources or motivation; consumed when participating in elections. +- **`confidence`**: Confidence in estimating the true color distribution. +- **`known_cells`**: Knowledge about a number of cells. + +#### **Key Methods** +1. **`ask_for_participation(area)`** + - Decides whether to participate in a given area's election. + - Returns `True` or `False`. + + ```python + # TODO + ``` + +2. **`decide_altruism_factor`** + - Uses a trained decision tree to determine the altruism factor for voting. + + ```python + # TODO + ``` + +3. **`update_known_cells(area)`** + - Updates the known cells by sampling the provided area. + +4. **`vote(area)`** + - Calculates the agent's preference ranking vector for the given area. + diff --git a/docs/technical/architecture_overview.md b/docs/technical/architecture_overview.md new file mode 100644 index 0000000..5f39343 --- /dev/null +++ b/docs/technical/architecture_overview.md @@ -0,0 +1,130 @@ +# Project Summary + +DemocracySim explores how voting rules impact participation and welfare +in an evolving environment that is influenced by agents' group decisions. +The framework enables **dynamic simulations** where agents interact with their environment +and adapt their strategies to maximize rewards. + +--- + +### Core Components + +#### **Grid-Based Mesa Model** +- A grid-based world containing cells, which represent states with colors (e.g., `white`, `red`, `green`, `blue`). +- Elections within *areas* of the grid influence color transitions. +- Wrap-around boundaries avoid border effects. + +## `ColorCell` Class Documentation + + ::: path.to.your.module.ColorCell + + +### Attributes + +- **`color`**: + - **Type**: `Color` + - **Description**: The `color` attribute defines the current color of the `ColorCell`. A `Color` object may represent RGB values, color names, or any other color representation. + - **Default Value**: `None` (or specify the default if applicable). + - **Purpose**: Used to identify and differentiate cells by their color. + +### Methods + +- **`setColor(color: Color)`**: Sets the color of the `ColorCell` to the specified value. +- **`getColor() -> Color`**: Returns the current color of the `ColorCell`. + +--- + +For additional details on the `Color` class, see the [Testlink](#Attributes). + + +#### **Agents** +- Decision-making units equipped with: + - **Personality vectors**: Represent color preferences. + - **Assets**: Manage a budget when making decisions. + - **Decision logic**: Participate in elections or not, cast their vote strategically. + +#### **Elections** +- Take place inside instances of the `Area` class. +- Options to vote upon represent color rank vectors. +- Are held periodically in *areas* and or globally. +- Outcomes: + - Influence the color mutation of cells in the *area*. + - Determine rewards for all agents in the area. + + +#### 4. **Reward Mechanisms** +- Rewards depend on: + - Proximity to the objective "truth" (closeness of the decided color ranking + to the "real" color frequency distribution within the area). + - Distributed according to both egalitarian and preference-based weighting. + +--- + +### Class Overview of the Environment + +```mermaid +classDiagram + class ParticipationModel { + + Grid grid + + List[Area] areas + + List[VoteAgent] agents + + int colors + + int election_costs + + numpy.ndarray options + + Function voting_rule + + Function distance_func + + step() + «static» + pers_dist(size) + «static» + create_all_options(num_colors) + «static» + create_all_options(color_distribution) + } + + class ColorCell { + + int unique_id + + int, int position + + int color + + bool is_border_cell + - List[VoteAgent] agents + } + + class Area { + + int unique_id + + List[ColorCell] cells + + numpy.ndarray color_distribution + + List[VoteAgent] agents + + numpy.ndarray personality_distribution + + int, int _idx_field + - int _width + - int _height + + numpy.ndarray voted_ordering + + int voter_turnout + + float dist_to_reality + - conduct_election() + - tally_votes() + - distribute_rewards() + + step() + } + + class VoteAgent { + + int unique_id + + int, int position + + int assets + + numpy.ndarray personality + + List[Optional[ColorCell]] known_cells + + float confidence + + int num_elections_participated + + ask_for_participation(area) + + estimate_real_distribution(area) + + decide_altruism_factor(area) + + compute_assumed_opt_dist(area) + + vote(area) + } + + + ParticipationModel --> "1..*" VoteAgent + ParticipationModel --> "1..*" Area + ParticipationModel --> "1..*" ColorCell + Area --> "1..*" ColorCell + VoteAgent --> "*..1" ColorCell + Area --> "1..*" VoteAgent +``` \ No newline at end of file diff --git a/docs/technical/areas.md b/docs/technical/areas.md new file mode 100644 index 0000000..a1cc548 --- /dev/null +++ b/docs/technical/areas.md @@ -0,0 +1,32 @@ +# Simulation Environment + +The environment in DemocracySim is structured as a grid, where elections and agent interactions occur. + +--- + +### Features +1. **Dynamic Environment**: + - The grid is composed of cells, each representing a state with a specific color. + - Elections in areas of the grid drive state transitions. + +2. **Mutations and Elections**: + - Mutations are applied to the grid, introducing randomness. + - Voting outcomes reflect agent personalities and decision-making. + +--- + +#### Area Workflow + +```mermaid +sequenceDiagram + participant Area + participant Election + participant Agent + Area->>Election: Area holds an election + Area->>Agent: Updates each agents knowledge + Agent->>Area: Has knowledge + Agent->>Election: Participate in election + Election->>Agent: Receive reward + Agent->>Agent: Update assets and strategies +``` + diff --git a/docs/technical/installation_instructions.md b/docs/technical/installation_instructions.md new file mode 100644 index 0000000..e832fa3 --- /dev/null +++ b/docs/technical/installation_instructions.md @@ -0,0 +1 @@ +# ToDo diff --git a/mkdocs.yml b/mkdocs.yml index 6523426..bd7228e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,6 +86,10 @@ plugins: - search - mkdocstrings: default_handler: python + handlers: + python: + options: + show_private_members: true # Extensions markdown_extensions: diff --git a/np_performance_test_1.py b/np_performance_test_1.py deleted file mode 100644 index d2d677a..0000000 --- a/np_performance_test_1.py +++ /dev/null @@ -1,15 +0,0 @@ -import time -import numpy as np -np.random.seed(42) -a = np.random.uniform(size=(300, 300)) -runtimes = 10 - -timecosts = [] -for _ in range(runtimes): - s_time = time.time() - for i in range(100): - a += 1 - np.linalg.svd(a) - timecosts.append(time.time() - s_time) - -print(f'mean of {runtimes} runs: {np.mean(timecosts):.5f}s') diff --git a/np_performance_test_2.py b/np_performance_test_2.py deleted file mode 100644 index 743283a..0000000 --- a/np_performance_test_2.py +++ /dev/null @@ -1,64 +0,0 @@ -# SOURCE: https://gist.github.com/markus-beuckelmann/8bc25531b11158431a5b09a45abd6276 - -import numpy as np -from time import time -from datetime import datetime - -start_time = datetime.now() - -# Let's take the randomness out of random numbers (for reproducibility) -np.random.seed(0) - -size = 4096 -A, B = np.random.random((size, size)), np.random.random((size, size)) -C, D = np.random.random((size * 128,)), np.random.random((size * 128,)) -E = np.random.random((int(size / 2), int(size / 4))) -F = np.random.random((int(size / 2), int(size / 2))) -F = np.dot(F, F.T) -G = np.random.random((int(size / 2), int(size / 2))) - -# Matrix multiplication -N = 20 -t = time() -for i in range(N): - np.dot(A, B) -delta = time() - t -print('Dotted two %dx%d matrices in %0.2f s.' % (size, size, delta / N)) -del A, B - -# Vector multiplication -N = 5000 -t = time() -for i in range(N): - np.dot(C, D) -delta = time() - t -print('Dotted two vectors of length %d in %0.2f ms.' % (size * 128, 1e3 * delta / N)) -del C, D - -# Singular Value Decomposition (SVD) -N = 3 -t = time() -for i in range(N): - np.linalg.svd(E, full_matrices = False) -delta = time() - t -print("SVD of a %dx%d matrix in %0.2f s." % (size / 2, size / 4, delta / N)) -del E - -# Cholesky Decomposition -N = 3 -t = time() -for i in range(N): - np.linalg.cholesky(F) -delta = time() - t -print("Cholesky decomposition of a %dx%d matrix in %0.2f s." % (size / 2, size / 2, delta / N)) - -# Eigendecomposition -t = time() -for i in range(N): - np.linalg.eig(G) -delta = time() - t -print("Eigendecomposition of a %dx%d matrix in %0.2f s." % (size / 2, size / 2, delta / N)) - -print('') -end_time = datetime.now() -print(f'TOTAL TIME = {(end_time - start_time).seconds} seconds') diff --git a/tests/test_utility_functions.py b/tests/test_utility_functions.py new file mode 100644 index 0000000..0589fac --- /dev/null +++ b/tests/test_utility_functions.py @@ -0,0 +1,59 @@ +import unittest +import numpy as np +from democracy_sim.participation_agent import combine_and_normalize + +class TestUtilityFunctions(unittest.TestCase): + """Test utility functions in the democracy_sim 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 From 3f379b4ff9825201edea5e1895116c89d6a75f4b Mon Sep 17 00:00:00 2001 From: jurikane Date: Fri, 12 Sep 2025 10:22:19 +0900 Subject: [PATCH 2/5] reorganizing for headless run setup --- {democracy_sim/config => configs}/config.yaml | 14 +- democracy_sim/run_headless.py | 128 ------------- democracy_sim/run_replay.py | 180 ------------------ .../headless_replay_README.md | 6 +- {democracy_sim => scripts}/replay_stubs.py | 0 {democracy_sim => scripts}/run.py | 10 +- {democracy_sim => src}/__init__.py | 0 .../agents}/participation_agent.py | 5 +- {democracy_sim => src}/model_setup.py | 8 +- {democracy_sim => src}/participation_model.py | 0 .../utils}/distance_functions.py | 0 .../utils}/social_welfare_functions.py | 0 .../utils}/visualisation_elements.py | 0 13 files changed, 22 insertions(+), 329 deletions(-) rename {democracy_sim/config => configs}/config.yaml (81%) delete mode 100644 democracy_sim/run_headless.py delete mode 100644 democracy_sim/run_replay.py rename {democracy_sim => scripts}/headless_replay_README.md (97%) rename {democracy_sim => scripts}/replay_stubs.py (100%) rename {democracy_sim => scripts}/run.py (80%) rename {democracy_sim => src}/__init__.py (100%) rename {democracy_sim => src/agents}/participation_agent.py (96%) rename {democracy_sim => src}/model_setup.py (97%) rename {democracy_sim => src}/participation_model.py (100%) rename {democracy_sim => src/utils}/distance_functions.py (100%) rename {democracy_sim => src/utils}/social_welfare_functions.py (100%) rename {democracy_sim => src/utils}/visualisation_elements.py (100%) diff --git a/democracy_sim/config/config.yaml b/configs/config.yaml similarity index 81% rename from democracy_sim/config/config.yaml rename to configs/config.yaml index 41f4a94..8802b5c 100644 --- a/democracy_sim/config/config.yaml +++ b/configs/config.yaml @@ -9,8 +9,8 @@ model: distance_idx: 1 # Model parameters - num_agents: 800 - common_assets: 40000 + num_agents: 5 + common_assets: 400 num_colors: 3 color_patches_steps: 3 patch_power: 1.0 @@ -27,17 +27,17 @@ model: draw_borders: true # Voting Areas - num_areas: 16 - av_area_height: 25 - av_area_width: 20 + num_areas: 2 + av_area_height: 50 + av_area_width: 80 area_size_variance: 0.0 # Statistics and Views show_area_stats: true simulation: - runs: 2 # number of independent seeds - num_steps: 100 + 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/democracy_sim/run_headless.py b/democracy_sim/run_headless.py deleted file mode 100644 index 884eef1..0000000 --- a/democracy_sim/run_headless.py +++ /dev/null @@ -1,128 +0,0 @@ -# fast_batch.py ── headless, parallel & compact -import yaml, argparse, multiprocessing as mp, json -from datetime import datetime -from pathlib import Path -import numpy as np -import pandas as pd -import pyarrow as pa, pyarrow.parquet as pq -from participation_model import ParticipationModel -from tqdm import tqdm - -# ─────────────────────────────── CONFIG ──────────────────────────────── -def load_yaml(path): - with open(path, "r") as f: - return yaml.safe_load(f) - -def _snapshot(model): - """ - Return an H×W uint8 array with the current grid colours. - Uses the pre-allocated model.color_cells list, so no grid - access is needed. - """ - return np.fromiter( - (cell.color for cell in model.color_cells), - dtype=np.uint8, - count=model.height * model.width - ).reshape(model.height, model.width) - - -# ─────────────────────── SINGLE-RUN EXECUTION ───────────────────────── - -def run_once(run_id, model_cfg, num_steps, store_grid, grid_interval, out_dir): - np.random.seed(run_id) - model = ParticipationModel(**model_cfg) - - # ——— dump static geometry ———————————————————————————— - # agents: (unique_id, x, y, personality_idx) - agent_arr = np.array( - [(a.unique_id, a.row, a.col, a.personality_idx) - for a in model.voting_agents], - dtype=np.int32 - ) - - # areas: (unique_id, x, y, height, width) - # note: Area stores its dimensions in _height/_width - area_arr = np.array( - [(ar.unique_id, - ar.idx_field[0], ar.idx_field[1], - ar._height, ar._width) - for ar in model.areas], - dtype=np.int32 - ) - - np.savez_compressed(out_dir / f"static_{run_id}.npz", - agents=agent_arr, - areas=area_arr) - - # ----- grid data - - model_records = [] - grids = [] if store_grid else None - - for step in range(num_steps): - model.step() - - row = model.datacollector.get_model_vars_dataframe().iloc[-1].to_dict() - row["run_id"] = run_id - row["step"] = step - model_records.append(row) - - if store_grid and step % grid_interval == 0: - grids.append(_snapshot(model)) - - df = pd.DataFrame(model_records) - pq.write_table(pa.Table.from_pandas(df), - out_dir / f"model_{run_id}.parquet") - - if store_grid: - np.savez_compressed(out_dir / f"grid_{run_id}.npz", - grid=np.stack(grids, axis=0)) - - del model, model_records, grids - return run_id - - -# ──────────────────────── PARALLEL BATCH DRIVER ─────────────────────── - -def _run_wrapper(args): - """Top-level helper so it can be pickled by multiprocessing.""" - return run_once(*args) - -def batch_run(cfg): - sim_cfg = cfg["simulation"] - model_cfg = cfg["model"] - - num_runs = sim_cfg.get("runs", 20) - num_steps = sim_cfg.get("num_steps", 1000) - processes = sim_cfg.get("processes", mp.cpu_count()) - store_grid = sim_cfg.get("store_grid", False) - grid_interval = max(1, sim_cfg.get("grid_interval", 1)) - - out_dir = Path(cfg["output"].get("directory", "runs")) / \ - datetime.now().strftime("%Y%m%d_%H%M%S") - out_dir.mkdir(parents=True, exist_ok=True) - - with open(out_dir / "meta.json", "w") as f: - json.dump(dict(model=model_cfg, simulation=sim_cfg), f, indent=2) - - print(f"▶ Launching {num_runs} runs on {processes} processes ...") - args = [(i, model_cfg, num_steps, store_grid, grid_interval, out_dir) - for i in range(num_runs)] - - # --- no lambdas, only a top-level function ------------------------ - with mp.get_context("spawn").Pool(processes) as pool: - for _ in tqdm(pool.imap_unordered(_run_wrapper, args), total=num_runs): - pass - - print("✔ all runs finished → results in", out_dir) - - -# ──────────────────────────── CLI ENTRY ─────────────────────────────── -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Fast parallel headless batch for Democracy-Sim" - ) - parser.add_argument("--config", "-c", default="config/config.yaml", - help="YAML file with model/simulation/output blocks") - config = load_yaml(parser.parse_args().config) - batch_run(config) diff --git a/democracy_sim/run_replay.py b/democracy_sim/run_replay.py deleted file mode 100644 index a331fb9..0000000 --- a/democracy_sim/run_replay.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Launch a Mesa web-server that *replays* a single batch run. - -Usage: - python replay_server.py [--run-dir ] [--run-id ] - -If no arguments are given, uses the most recent run in ./runs/ -""" - -import argparse, json, numpy as np, pyarrow.parquet as pq -from pathlib import Path -import mesa -from mesa.visualization.ModularVisualization import ModularServer -from participation_model import ColorCell, VoteAgent, Area, distance_functions -from model_setup import ( - canvas_element, - voter_turnout, - wealth_chart, - color_distribution_chart, -) -from itertools import combinations, permutations, product - - -# ──────────────────── REPLAY MODEL ────────────────────────── -class ParticipationReplay(mesa.Model): - """A lightweight wrapper that replays pre-computed states.""" - def __init__(self, run_path: Path): - self.meta = json.loads((run_path / "meta.json").read_text()) - self.model_df = pq.read_table(next(run_path.glob("model_*.parquet")) - ).to_pandas().sort_values("step") - static_file = next(run_path.glob("static_*.npz")) - static = np.load(static_file) - agents_data = static["agents"] - areas_data = static["areas"] - grid_file = next(run_path.glob("grid_*.npz"), None) - if grid_file is None: - raise FileNotFoundError("Grids were not stored for this run.") - self.grids = np.load(grid_file)["grid"] # (T, H, W) - - # restore params - p = self.meta["model"] - self.height, self.width = p["height"], p["width"] - self.schedule = mesa.time.BaseScheduler(self) - self.draw_borders = p.get("draw_borders", True) - # Add attributes needed by VoteAgent - self.known_cells = p.get("known_cells", 10) # Default value from model_setup.py - self.num_colors = p.get("num_colors", 3) # Default value from model_setup.py - - # Add more attributes needed by VoteAgent - distance_idx = p.get("distance_idx", 0) # Default to first distance function - self.distance_func = distance_functions[distance_idx] - self.options = self.create_all_options(self.num_colors) - self.color_search_pairs = list(combinations(range(0, self.num_colors), 2)) - - # build grid + cells - self.grid = mesa.space.MultiGrid(self.width, self.height, torus=False) - cells = [] - uid = 0 - for y in range(self.height): - for x in range(self.width): - cell = ColorCell(uid, self, (x, y), 0) - self.grid.place_agent(cell, (x, y)) - cells.append(cell) - uid += 1 - self._cells = cells - - # recreate agents - self.voting_agents = [] - for uid, x, y, pers_idx in agents_data: - a = VoteAgent(int(uid), self, (int(x), int(y)), - personality_idx=int(pers_idx)) - self.voting_agents.append(a) - self.grid.place_agent(a, (int(x), int(y))) - - # recreate areas - self.areas = [] - for uid, x, y, h, w in areas_data: - var = self.meta["model"]["area_size_variance"] - ar = Area(int(uid), self, int(h), int(w), var) - ar.idx_field = (int(x), int(y)) - self.areas.append(ar) - - # fake DataCollector - first_row = self.model_df.iloc[0] - reporters = {k: (lambda m, k=k: m._current_row[k]) - for k in first_row.keys() if k not in ("run_id", "step")} - self.datacollector = mesa.DataCollector(model_reporters=reporters) - self._current_row = first_row.to_dict() - self.datacollector.collect(self) - - self._t = 0 - self.running = True - - def step(self): - if self._t + 1 >= len(self.grids): - self.running = False - return - self._t += 1 - self._current_row = self.model_df.iloc[self._t].to_dict() - colours_flat = self.grids[self._t].ravel() - for i, cell in enumerate(self._cells): - cell.color = int(colours_flat[i]) - self.datacollector.collect(self) - - @staticmethod - def create_all_options(n: int, include_ties=False): - """ - Creates a matrix (an array of all possible ranking vectors), - if specified including ties. - Rank values start from 0. - - Args: - n (int): The number of items to rank (number of colors in our case) - include_ties (bool): If True, rankings include ties. - - Returns: - np.array: A matrix containing all possible ranking vectors. - """ - if include_ties: - # Create all possible combinations and sort out invalid rankings - # i.e. [1, 1, 1] or [1, 2, 2] aren't valid as no option is ranked first. - r = np.array([np.array(comb) for comb in product(range(n), repeat=n) - if set(range(max(comb))).issubset(comb)]) - else: - r = np.array([np.array(p) for p in permutations(range(n))]) - return r - - -# ─────────────────────── server launcher ────────────────────── -def launch(run_dir: str, run_id: int): - run_path = Path(run_dir) - if run_id >= 0: - run_path = run_path / f"model_{run_id}.parquet" - if run_path.exists(): - run_path = run_path.parent - else: - raise FileNotFoundError("run_id not found in that directory.") - - model_params = {"run_path": run_path} - - server = ModularServer( - ParticipationReplay, - [canvas_element, wealth_chart, - color_distribution_chart, voter_turnout], - "Democracy-Sim replay", - model_params, - ) - server.port = 8585 - server.launch() - - -# ────────────────────────── CLI ─────────────────────────────── -if __name__ == "__main__": - ap = argparse.ArgumentParser() - ap.add_argument("--run-dir", - help="Path to a run folder (e.g. simulation_output/20250425_101530)") - ap.add_argument("--run-id", type=int, default=-1, - help="Run index to replay (model_#.parquet). Default = -1 = first one") - args = ap.parse_args() - - if args.run_dir is None: - runs_root = Path("simulation_output") - run_dirs = sorted([d for d in runs_root.iterdir() if d.is_dir()], - key=lambda p: p.name, reverse=True) - if not run_dirs: - raise FileNotFoundError( - "No run directories found in ./simulation_output/") - - print("Available simulation runs:") - for i, run in enumerate(run_dirs): - print(f" [{i}] {run.name}") - - selection = input("Select a run by number: ").strip() - if not selection.isdigit() or not (0 <= int(selection) < len(run_dirs)): - raise ValueError("Invalid selection.") - - args.run_dir = str(run_dirs[int(selection)]) - print(f"[INFO] Selected: {args.run_dir}") - - launch(**vars(args)) diff --git a/democracy_sim/headless_replay_README.md b/scripts/headless_replay_README.md similarity index 97% rename from democracy_sim/headless_replay_README.md rename to scripts/headless_replay_README.md index fbbdc41..83538a8 100644 --- a/democracy_sim/headless_replay_README.md +++ b/scripts/headless_replay_README.md @@ -21,7 +21,7 @@ This workflow allows you to: To run a simulation in headless mode: ```bash -cd democracy_sim +cd src python run_headless.py ``` @@ -35,7 +35,7 @@ By default, this will: You can specify a custom configuration file: ```bash -python run_headless.py --config path/to/your/config.yaml +python run_headless.py --configs path/to/your/configs.yaml ``` ### Output Files @@ -56,7 +56,7 @@ The headless simulation produces two main output files: To replay a previously saved simulation: ```bash -cd democracy_sim +cd src python run_replay.py ``` diff --git a/democracy_sim/replay_stubs.py b/scripts/replay_stubs.py similarity index 100% rename from democracy_sim/replay_stubs.py rename to scripts/replay_stubs.py diff --git a/democracy_sim/run.py b/scripts/run.py similarity index 80% rename from democracy_sim/run.py rename to scripts/run.py index 2e6f0fa..5570371 100644 --- a/democracy_sim/run.py +++ b/scripts/run.py @@ -1,9 +1,9 @@ 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 * +from src.participation_model import ParticipationModel +from src.model_setup import (model_params as params, canvas_element, + voter_turnout, wealth_chart, + color_distribution_chart) +from src.utils.visualisation_elements import * class CustomModularServer(ModularServer): diff --git a/democracy_sim/__init__.py b/src/__init__.py similarity index 100% rename from democracy_sim/__init__.py rename to src/__init__.py diff --git a/democracy_sim/participation_agent.py b/src/agents/participation_agent.py similarity index 96% rename from democracy_sim/participation_agent.py rename to src/agents/participation_agent.py index caa36c4..25fdd6a 100644 --- a/democracy_sim/participation_agent.py +++ b/src/agents/participation_agent.py @@ -2,7 +2,7 @@ import numpy as np from mesa import Agent if TYPE_CHECKING: # Type hint for IDEs - from democracy_sim.participation_model import ParticipationModel + from src.participation_model import ParticipationModel def combine_and_normalize(arr_1: np.array, arr_2: np.array, factor: float): @@ -156,6 +156,7 @@ def decide_altruism_factor(self, area): def compute_assumed_opt_dist(self, area): """ + # 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 as required | also think about making classes for orders and dists to not confuse them and have it set up correctly and well documented 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 to vote for or not). It takes "altruistic" concepts into consideration. @@ -186,7 +187,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]!) ############## diff --git a/democracy_sim/model_setup.py b/src/model_setup.py similarity index 97% rename from democracy_sim/model_setup.py rename to src/model_setup.py index 5b175e5..9086b54 100644 --- a/democracy_sim/model_setup.py +++ b/src/model_setup.py @@ -3,10 +3,10 @@ """ from typing import TYPE_CHECKING, cast from mesa.visualization.modules import ChartModule -from participation_agent import ColorCell -from participation_model import (ParticipationModel, - distance_functions, - social_welfare_functions) +from src.agents.participation_agent import ColorCell +from src.participation_model import (ParticipationModel, + distance_functions, + social_welfare_functions) from math import factorial import mesa diff --git a/democracy_sim/participation_model.py b/src/participation_model.py similarity index 100% rename from democracy_sim/participation_model.py rename to src/participation_model.py diff --git a/democracy_sim/distance_functions.py b/src/utils/distance_functions.py similarity index 100% rename from democracy_sim/distance_functions.py rename to src/utils/distance_functions.py diff --git a/democracy_sim/social_welfare_functions.py b/src/utils/social_welfare_functions.py similarity index 100% rename from democracy_sim/social_welfare_functions.py rename to src/utils/social_welfare_functions.py diff --git a/democracy_sim/visualisation_elements.py b/src/utils/visualisation_elements.py similarity index 100% rename from democracy_sim/visualisation_elements.py rename to src/utils/visualisation_elements.py From 1373f28a8108d63ef1e197a5c1843f60acc2ce51 Mon Sep 17 00:00:00 2001 From: jurikane Date: Fri, 12 Sep 2025 15:41:33 +0900 Subject: [PATCH 3/5] finished rearrange and cleanup before adding headless run --- configs/config.yaml | 13 +- configs/toy.yaml | 47 ++++++ scripts/__init__.py | 0 scripts/run.py | 43 +++-- src/model_setup.py | 198 ++++++++--------------- src/participation_model.py | 26 +-- src/utils/__init__.py | 0 src/utils/visualisation_elements.py | 4 +- tests/factory.py | 2 +- tests/test_approval_voting.py | 2 +- tests/test_color_by_dst.py | 2 +- tests/test_distance_functions.py | 2 +- tests/test_majority_rule.py | 2 +- tests/test_participation_area_agent.py | 8 +- tests/test_participation_model.py | 26 +-- tests/test_participation_voting_agent.py | 7 +- tests/test_update_color_distribution.py | 3 +- tests/test_utility_functions.py | 4 +- 18 files changed, 194 insertions(+), 195 deletions(-) create mode 100644 configs/toy.yaml create mode 100644 scripts/__init__.py create mode 100644 src/utils/__init__.py diff --git a/configs/config.yaml b/configs/config.yaml index 8802b5c..4a62f13 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,4 +1,4 @@ -# config.yaml +# configs.yaml model: # Elections election_costs: 1 @@ -9,8 +9,8 @@ model: distance_idx: 1 # Model parameters - num_agents: 5 - common_assets: 400 + num_agents: 800 + common_assets: 40000 num_colors: 3 color_patches_steps: 3 patch_power: 1.0 @@ -23,13 +23,14 @@ model: # Grid parameters height: 100 # (grid_rows) width: 80 # (grid_cols) + cell_size: 10 draw_borders: true # Voting Areas - num_areas: 2 - av_area_height: 50 - av_area_width: 80 + num_areas: 16 + av_area_height: 25 + av_area_width: 20 area_size_variance: 0.0 # Statistics and Views diff --git a/configs/toy.yaml b/configs/toy.yaml new file mode 100644 index 0000000..76895db --- /dev/null +++ b/configs/toy.yaml @@ -0,0 +1,47 @@ +# To use this configuration, set: CONFIG_FILE=toy.yaml +model: + # Elections + election_costs: 1 + max_reward: 50 + election_impact_on_mutation: 1.8 + mu: 0.05 + rule_idx: 1 + distance_idx: 1 + + # Model parameters + num_agents: 5 + common_assets: 500 + 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) + cell_size: 50 + + draw_borders: true + + # Voting Areas + num_areas: 2 + av_area_height: 10 + av_area_width: 10 + area_size_variance: 0.0 + + # 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 + +output: + directory: "simulation_output" \ No newline at end of file diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/run.py b/scripts/run.py index 5570371..cc1fefb 100644 --- a/scripts/run.py +++ b/scripts/run.py @@ -1,29 +1,32 @@ from mesa.visualization.ModularVisualization import ModularServer from src.participation_model import ParticipationModel -from src.model_setup import (model_params as params, canvas_element, - voter_turnout, wealth_chart, - color_distribution_chart) -from src.utils.visualisation_elements import * - +from src.model_setup import ( + model_params as params, + canvas_element, + voter_turnout, + wealth_chart, + color_distribution_chart, +) +from src.utils.visualisation_elements import ( + PersonalityDistribution, + AreaStats, + VoterTurnoutElement, + AreaPersonalityDists, +) 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. - """ + """Prevents double initialization of the model.""" 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) + 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 + return super().reset_model() - personality_distribution = PersonalityDistribution() area_stats = AreaStats() vto_areas = VoterTurnoutElement() @@ -31,10 +34,16 @@ def reset_model(self): 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], + 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, ) diff --git a/src/model_setup.py b/src/model_setup.py index 9086b54..0fb4e7b 100644 --- a/src/model_setup.py +++ b/src/model_setup.py @@ -4,96 +4,35 @@ from typing import TYPE_CHECKING, cast from mesa.visualization.modules import ChartModule from src.agents.participation_agent import ColorCell -from src.participation_model import (ParticipationModel, - distance_functions, - social_welfare_functions) +from src.participation_model import ( + ParticipationModel, distance_functions, social_welfare_functions +) from math import factorial +from pathlib import Path import mesa +import yaml +import os -# 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 +def load_config(config_file=None): + if config_file is None: + config_file = os.environ.get("CONFIG_FILE", "config.yaml") + config_path = Path(__file__).parent.parent / 'configs' / config_file + with open(config_path, 'r') as f: + conf = yaml.safe_load(f) + return conf +# Load config +config = load_config() +cfg = config["model"] + +# Colors _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", + "White", "Red", "Green", "Blue", "Yellow", "Aqua", "Fuchsia", + "Lime", "Maroon", "Orange" ] # 10 colors - def participation_draw(cell: ColorCell): """ This function is registered with the visualization server to be called @@ -109,22 +48,22 @@ def participation_draw(cell: ColorCell): 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} + "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 + if TYPE_CHECKING: 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 + portrayal["r"] = 0.9 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] + # Print number of agents in the cell if there are any if cell.num_agents_in_cell > 0: - portrayal[f"text"] = str(cell.num_agents_in_cell) + portrayal["text"] = str(cell.num_agents_in_cell) portrayal["text_color"] = "Black" for a in cell.areas: unique_id = a.unique_id @@ -137,119 +76,120 @@ def participation_draw(cell: ColorCell): portrayal[f"Agent {voter.unique_id}"] = text return portrayal - canvas_element = mesa.visualization.CanvasGrid( - participation_draw, grid_cols, grid_rows, canvas_width, canvas_height + participation_draw, + cfg["width"], + cfg["height"], + cfg["width"] * cfg["cell_size"], + cfg["height"] * cfg["cell_size"] ) - -wealth_chart = mesa.visualization.modules.ChartModule( +wealth_chart = ChartModule( [{"Label": "Collective assets", "Color": "Black"}], data_collector_name='datacollector' ) +color_distribution_chart = ChartModule( + [{"Label": f"Color {i}", + "Color": "LightGrey" if _COLORS[i] == "White" else _COLORS[i]} + for i in range(len(_COLORS))], + 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( +voter_turnout = ChartModule( [{"Label": "Voter turnout globally (in percent)", "Color": "Black"}, {"Label": "Gini Index (0-100)", "Color": "Red"}], - data_collector_name='datacollector') - + data_collector_name='datacollector' +) model_params = { - "height": grid_rows, - "width": grid_cols, + "height": cfg["height"], + "width": cfg["width"], "draw_borders": mesa.visualization.Checkbox( - name="Draw border cells", value=draw_borders + name="Draw border cells", value=cfg.get("draw_borders", True) ), "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, + value=cfg["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, + value=cfg["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, + name="Election costs", value=cfg["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, + name="Maximal reward", value=cfg["max_reward"], min_value=0, + max_value=cfg["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, + name="Mutation rate", value=cfg["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, + name="Election impact on mutation", value=cfg["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, + name="# Agents", value=cfg["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), + name="# Colors", value=cfg["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 + name="# different personalities", value=cfg["num_personalities"], + min_value=1, max_value=factorial(cfg["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 + name="Initial common assets", value=cfg["common_assets"], + min_value=cfg["num_agents"], max_value=1000*cfg["num_agents"], step=10 ), "known_cells": mesa.visualization.Slider( - name="# known fields", value=known_cells, + name="# known fields", value=cfg["known_cells"], min_value=1, max_value=100, step=1 ), "color_patches_steps": mesa.visualization.Slider( - name="Patches size (# steps)", value=color_patches_steps, + name="Patches size (# steps)", value=cfg["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, + name="Patches power", value=cfg["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, + value=cfg["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 + name=f"# Areas within the {cfg['height']}x{cfg['width']} world", step=1, + value=cfg["num_areas"], min_value=1, max_value=min(cfg["width"], cfg["height"])//2 ), "av_area_height": mesa.visualization.Slider( - name="Av. area height", value=av_area_height, - min_value=2, max_value=grid_rows//2, + name="Av. area height", value=cfg["av_area_height"], + min_value=2, max_value=cfg["height"]//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, + name="Av. area width", value=cfg["av_area_width"], + min_value=2, max_value=cfg["width"]//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, + name="Area size variance", value=cfg["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 - ), + name="Show all statistics", value=cfg.get("show_area_stats", True) + ), } diff --git a/src/participation_model.py b/src/participation_model.py index d360f3d..e0b1e5f 100644 --- a/src/participation_model.py +++ b/src/participation_model.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING, cast, List, Optional import mesa -from participation_agent import VoteAgent, ColorCell -from social_welfare_functions import majority_rule, approval_voting -from distance_functions import spearman, kendall_tau +from src.agents.participation_agent import 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 math import sqrt import numpy as np @@ -142,7 +142,11 @@ def idx_field(self, pos: tuple): 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] + 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 @@ -150,8 +154,8 @@ def idx_field(self, pos: tuple): 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 + # 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 @@ -731,15 +735,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) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/visualisation_elements.py b/src/utils/visualisation_elements.py index bbc4bdf..36c09f9 100644 --- a/src/utils/visualisation_elements.py +++ b/src/utils/visualisation_elements.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, cast from mesa.visualization import TextElement import matplotlib.patches as patches -from model_setup import _COLORS +from src.model_setup import _COLORS import base64 import math import io @@ -210,7 +210,7 @@ def create_once(self, model): 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) + figsize=(8, num_areas), sharex=True) for ax, area in zip(axes.flatten(), model.areas): # Fetch data p_dist = area.personality_distribution diff --git a/tests/factory.py b/tests/factory.py index 3379e66..628b0f2 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -1,4 +1,4 @@ -from democracy_sim.participation_model import ParticipationModel +from src.participation_model import ParticipationModel def create_default_model(**overrides): 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_color_by_dst.py b/tests/test_color_by_dst.py index ce80cc0..02b0006 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.participation_model import ParticipationModel class TestColorByDst(unittest.TestCase): 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_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_participation_area_agent.py b/tests/test_participation_area_agent.py index dc84c86..4370a81 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 src.participation_model import Area +from src.agents.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.utils.social_welfare_functions import majority_rule, approval_voting +from src.utils.distance_functions import kendall_tau, spearman class TestArea(unittest.TestCase): diff --git a/tests/test_participation_model.py b/tests/test_participation_model.py index 31cfaa6..38eba9e 100644 --- a/tests/test_participation_model.py +++ b/tests/test_participation_model.py @@ -1,17 +1,17 @@ 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.participation_model import (ParticipationModel, Area, + distance_functions, + social_welfare_functions) +from src.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) import mesa diff --git a/tests/test_participation_voting_agent.py b/tests/test_participation_voting_agent.py index 4f49a6a..f365b36 100644 --- a/tests/test_participation_voting_agent.py +++ b/tests/test_participation_voting_agent.py @@ -1,6 +1,6 @@ from .test_participation_model import * -from democracy_sim.participation_model import Area -from democracy_sim.participation_agent import VoteAgent, combine_and_normalize +from src.participation_model import Area +from src.agents.participation_agent import VoteAgent, combine_and_normalize import numpy as np import random @@ -51,7 +51,8 @@ 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)) + # TODO: This test fails sometimes (11.09.25) + self.assertEqual(list(comb), list(est_dist)) # <--- here elif a_factor == 1.0: if sum(own_prefs) != 1.0: own_prefs = own_prefs / sum(own_prefs) 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 index 0589fac..7e2c1cc 100644 --- a/tests/test_utility_functions.py +++ b/tests/test_utility_functions.py @@ -1,9 +1,9 @@ import unittest import numpy as np -from democracy_sim.participation_agent import combine_and_normalize +from src.agents.participation_agent import combine_and_normalize class TestUtilityFunctions(unittest.TestCase): - """Test utility functions in the democracy_sim package.""" + """Test utility functions in the src package.""" def test_combine_and_normalize_basic(self): """Test basic functionality of combine_and_normalize.""" From 5cc1543e5f57e10d435fbe20279e100e2aa3049f Mon Sep 17 00:00:00 2001 From: jurikane Date: Wed, 17 Sep 2025 19:10:03 +0900 Subject: [PATCH 4/5] Fixes after rearrange and cleanup and changes in visualization --- configs/config.yaml | 8 ++- configs/toy.yaml | 8 ++- src/model_setup.py | 23 ++++--- src/participation_model.py | 78 ++++++++++++++---------- src/utils/visualisation_elements.py | 25 ++++---- tests/factory.py | 31 ++-------- tests/test_participation_area_agent.py | 4 +- tests/test_participation_model.py | 58 +++++------------- tests/test_participation_voting_agent.py | 10 ++- 9 files changed, 113 insertions(+), 132 deletions(-) diff --git a/configs/config.yaml b/configs/config.yaml index 4a62f13..365f24a 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -23,9 +23,6 @@ model: # Grid parameters height: 100 # (grid_rows) width: 80 # (grid_cols) - cell_size: 10 - - draw_borders: true # Voting Areas num_areas: 16 @@ -33,6 +30,11 @@ model: 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 diff --git a/configs/toy.yaml b/configs/toy.yaml index 76895db..f32965f 100644 --- a/configs/toy.yaml +++ b/configs/toy.yaml @@ -23,9 +23,6 @@ model: # Grid parameters height: 20 # (grid_rows) width: 10 # (grid_cols) - cell_size: 50 - - draw_borders: true # Voting Areas num_areas: 2 @@ -33,6 +30,11 @@ model: av_area_width: 10 area_size_variance: 0.0 +# Visualization parameters +visualization: + # Grid parameters + cell_size: 10 + draw_borders: true # Statistics and Views show_area_stats: true diff --git a/src/model_setup.py b/src/model_setup.py index 0fb4e7b..c6f94cb 100644 --- a/src/model_setup.py +++ b/src/model_setup.py @@ -26,6 +26,7 @@ def load_config(config_file=None): # Load config config = load_config() cfg = config["model"] +vis_cfg = config.get("visualization", {}) # Colors _COLORS = [ @@ -47,13 +48,14 @@ def participation_draw(cell: ColorCell): if cell is None: raise AssertionError color = _COLORS[cell.color] + draw_borders = vis_cfg.get("draw_borders", True) 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: cell.model = cast(ParticipationModel, cell.model) - if cell.is_border_cell and cell.model.draw_borders: + if cell.is_border_cell and draw_borders: portrayal["Shape"] = "circle" portrayal["r"] = 0.9 if color == "White": @@ -80,8 +82,8 @@ def participation_draw(cell: ColorCell): participation_draw, cfg["width"], cfg["height"], - cfg["width"] * cfg["cell_size"], - cfg["height"] * cfg["cell_size"] + cfg["width"] * vis_cfg["cell_size"], + cfg["height"] * vis_cfg["cell_size"] ) wealth_chart = ChartModule( @@ -102,12 +104,18 @@ def participation_draw(cell: ColorCell): data_collector_name='datacollector' ) +visualization_params = { + "draw_borders": mesa.visualization.Checkbox( + name="Draw border cells", value=vis_cfg.get("draw_borders", True) + ), + "show_area_stats": mesa.visualization.Checkbox( + name="Show all statistics", value=cfg.get("show_area_stats", True) + ), +} + model_params = { "height": cfg["height"], "width": cfg["width"], - "draw_borders": mesa.visualization.Checkbox( - name="Draw border cells", value=cfg.get("draw_borders", True) - ), "rule_idx": mesa.visualization.Slider( name=f"Rule index {[r.__name__ for r in social_welfare_functions]}", value=cfg["rule_idx"], min_value=0, max_value=len(social_welfare_functions)-1, @@ -189,7 +197,4 @@ def participation_draw(cell: ColorCell): 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=cfg.get("show_area_stats", True) - ), } diff --git a/src/participation_model.py b/src/participation_model.py index e0b1e5f..c6adc6c 100644 --- a/src/participation_model.py +++ b/src/participation_model.py @@ -4,7 +4,6 @@ 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 math import sqrt import numpy as np # Voting rules to be accessible by index @@ -173,8 +172,11 @@ def _update_personality_distribution(self) -> None: 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] + 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: """ @@ -211,10 +213,21 @@ def _conduct_election(self) -> int: # 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: + 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") - return 0 # TODO: What to do in this case? Cease the simulation? - # Aggregate the preferences ⇒ returns an option ordering + # 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] @@ -241,8 +254,7 @@ def _tally_votes(self): """ preference_profile = [] for agent in self.agents: - model = self.model - el_costs = model.election_costs + el_costs = self.model.election_costs # Give agents their (new) known fields agent.update_known_cells(area=self) if (agent.assets >= el_costs @@ -278,9 +290,8 @@ def _distribute_rewards(self) -> None: # 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 + 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: """ @@ -537,7 +548,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. """ @@ -545,9 +555,8 @@ 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): super().__init__() # TODO clean up class (public/private variables) self.height = height @@ -562,7 +571,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 @@ -582,15 +590,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 @@ -605,8 +613,6 @@ 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): @@ -632,34 +638,42 @@ def num_areas(self): def preset_color_dst(self): return len(self._preset_color_dst) - def _initialize_color_cells(self): + def _initialize_color_cells(self, id_start=0): """ - 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): """ 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) @@ -667,10 +681,10 @@ def initialize_voting_agents(self): personality_idx = rng.choice(len(self.personalities), 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, + agent = VoteAgent(unique_id, self, (x, y), personality, personality_idx, 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 + 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) diff --git a/src/utils/visualisation_elements.py b/src/utils/visualisation_elements.py index 36c09f9..2e938b6 100644 --- a/src/utils/visualisation_elements.py +++ b/src/utils/visualisation_elements.py @@ -1,13 +1,14 @@ import matplotlib.pyplot as plt -from typing import TYPE_CHECKING, cast from mesa.visualization import TextElement import matplotlib.patches as patches -from src.model_setup import _COLORS +from src.model_setup import _COLORS, vis_cfg import base64 import math import io _COLORS[0] = "LightGray" +# Visualization config +show_area_stats = vis_cfg.get("show_area_stats", True) def save_plot_to_base64(fig): buf = io.BytesIO() @@ -23,7 +24,7 @@ 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 @@ -85,8 +86,6 @@ def __init__(self): 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 @@ -96,7 +95,7 @@ def create_once(self, model): num_colors = len(personalities[0]) fig, ax = plt.subplots(figsize=(6, 4)) - heights = dists * num_agents + heights = dists # * num_agents bars = ax.bar(range(num_personalities), heights, width=0.6) for bar, personality in zip(bars, personalities): @@ -111,7 +110,7 @@ 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() @@ -128,7 +127,7 @@ 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() @@ -162,7 +161,7 @@ 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() @@ -197,9 +196,6 @@ def __init__(self): self.areas_pers_dist_plot = None def create_once(self, model): - if TYPE_CHECKING: - model = cast('ParticipationModel', model) - colors = _COLORS[:model.num_colors] personalities = model.personalities num_colors = len(personalities[0]) @@ -218,6 +214,9 @@ def create_once(self, model): # Subplot heights = [int(val * num_agents) for val in p_dist] bars = ax.bar(range(num_personalities), heights, color='skyblue') + # Set the top of all bars to the color code of the personality + max_height = max(heights) if heights else 1 + p_top_hight = max_height * 0.02 # Top 2% colored acc. to personality for bar, personality in zip(bars, personalities): height = bar.get_height() @@ -226,7 +225,7 @@ def create_once(self, model): 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) diff --git a/tests/factory.py b/tests/factory.py index 628b0f2..eedeeb5 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -1,31 +1,12 @@ from src.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/test_participation_area_agent.py b/tests/test_participation_area_agent.py index 4370a81..04d0fe8 100644 --- a/tests/test_participation_area_agent.py +++ b/tests/test_participation_area_agent.py @@ -3,7 +3,7 @@ import numpy as np from src.participation_model import Area from src.agents.participation_agent import VoteAgent -from .test_participation_model import TestParticipationModel, num_agents +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 @@ -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 38eba9e..a227969 100644 --- a/tests/test_participation_model.py +++ b/tests/test_participation_model.py @@ -2,42 +2,16 @@ from src.participation_model import (ParticipationModel, Area, distance_functions, social_welfare_functions) -from src.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.model_setup import config import mesa +model_cfg = config["model"] +vis_cfg = config.get("visualization", {}) 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 +26,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 +53,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 +67,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 f365b36..2716007 100644 --- a/tests/test_participation_voting_agent.py +++ b/tests/test_participation_voting_agent.py @@ -1,9 +1,13 @@ -from .test_participation_model import * +from .test_participation_model import TestParticipationModel +import unittest from src.participation_model import Area from src.agents.participation_agent import VoteAgent, combine_and_normalize import numpy as np import random +from src.model_setup import config +model_cfg = config["model"] +vis_cfg = config.get("visualization", {}) class TestVotingAgent(unittest.TestCase): @@ -12,8 +16,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) From 6412b48b531b69334ba97c4230482fbd79ce57fd Mon Sep 17 00:00:00 2001 From: jurikane Date: Sat, 27 Sep 2025 15:29:00 +0900 Subject: [PATCH 5/5] restructuring of the project and major cleanup - before continuing with headless run implementation --- .gitignore | 105 +- configs/config.yaml | 5 +- configs/default.yaml | 42 + configs/toy.yaml | 13 +- docs/technical/agents.md | 41 - docs/technical/api/Area.md | 4 +- docs/technical/api/ColorCell.md | 2 +- docs/technical/api/Model.md | 2 +- docs/technical/api/Utility_functions.md | 3 - docs/technical/api/VoteAgent.md | 2 +- docs/technical/api/inherited.md | 1 - docs/technical/api/utility_functions.md | 25 + docs/technical/approval_voting.md | 56 - docs/technical/architecture_overview.md | 130 --- docs/technical/areas.md | 32 - docs/technical/installation_instructions.md | 1 - docs/technical/preference_relations.md | 23 - mkdocs.yml | 2 +- pyproject.toml | 104 +- requirements.txt | 63 +- scripts/headless_replay_README.md | 101 -- scripts/replay_stubs.py | 18 - scripts/run.py | 69 +- src/agents/__init__.py | 7 + src/agents/area.py | 354 ++++++ src/agents/color_cell.py | 89 ++ .../{participation_agent.py => vote_agent.py} | 152 +-- src/config/__init__.py | 0 src/config/loader.py | 53 + src/config/schema.py | 54 + src/model_setup.py | 379 +++--- src/models/__init__.py | 0 src/{ => models}/participation_model.py | 593 ++-------- src/utils/distance_functions.py | 181 +-- src/utils/metrics.py | 53 + src/utils/social_welfare_functions.py | 61 +- src/viz/__init__.py | 0 src/viz/factory.py | 135 +++ src/{utils => viz}/visualisation_elements.py | 102 +- tests/LICENSE | 13 - tests/__init__.py | 2 + tests/factory.py | 2 +- tests/read_requirements.py | 8 - tests/test_agent.py | 284 ----- tests/test_batch_run.py | 200 ---- tests/test_cell_space.py | 463 -------- tests/test_color_by_dst.py | 2 +- tests/test_create_personalities.py | 4 +- tests/test_datacollector.py | 234 ---- tests/test_devs.py | 282 ----- tests/test_end_to_end_viz.sh | 8 - tests/test_examples.py | 68 -- tests/test_grid.py | 507 -------- tests/test_import_namespace.py | 27 - tests/test_lifespan.py | 95 -- tests/test_main.py | 34 - tests/test_model.py | 53 - tests/test_participation_area_agent.py | 4 +- tests/test_participation_model.py | 14 +- tests/test_participation_voting_agent.py | 19 +- tests/test_pers_dist.py | 55 +- tests/test_scaffold.py | 22 - tests/test_set_dimensions.py | 28 - tests/test_space.py | 1038 ----------------- tests/test_time.py | 342 ------ tests/test_utility_functions.py | 2 +- 66 files changed, 1586 insertions(+), 5281 deletions(-) create mode 100644 configs/default.yaml delete mode 100644 docs/technical/agents.md delete mode 100644 docs/technical/api/Utility_functions.md create mode 100644 docs/technical/api/utility_functions.md delete mode 100644 docs/technical/approval_voting.md delete mode 100644 docs/technical/architecture_overview.md delete mode 100644 docs/technical/areas.md delete mode 100644 docs/technical/installation_instructions.md delete mode 100644 docs/technical/preference_relations.md delete mode 100644 scripts/headless_replay_README.md delete mode 100644 scripts/replay_stubs.py create mode 100644 src/agents/__init__.py create mode 100644 src/agents/area.py create mode 100644 src/agents/color_cell.py rename src/agents/{participation_agent.py => vote_agent.py} (64%) create mode 100644 src/config/__init__.py create mode 100644 src/config/loader.py create mode 100644 src/config/schema.py create mode 100644 src/models/__init__.py rename src/{ => models}/participation_model.py (53%) create mode 100644 src/utils/metrics.py create mode 100644 src/viz/__init__.py create mode 100644 src/viz/factory.py rename src/{utils => viz}/visualisation_elements.py (73%) delete mode 100644 tests/LICENSE delete mode 100644 tests/read_requirements.py delete mode 100644 tests/test_agent.py delete mode 100644 tests/test_batch_run.py delete mode 100644 tests/test_cell_space.py delete mode 100644 tests/test_datacollector.py delete mode 100644 tests/test_devs.py delete mode 100755 tests/test_end_to_end_viz.sh delete mode 100644 tests/test_examples.py delete mode 100644 tests/test_grid.py delete mode 100644 tests/test_import_namespace.py delete mode 100644 tests/test_lifespan.py delete mode 100644 tests/test_main.py delete mode 100644 tests/test_model.py delete mode 100644 tests/test_scaffold.py delete mode 100644 tests/test_set_dimensions.py delete mode 100644 tests/test_space.py delete mode 100644 tests/test_time.py diff --git a/.gitignore b/.gitignore index 6a5ecb6..178fe52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,84 @@ -/.idea +# OS .DS_Store -.junie -TODO.txt +.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 -/democracy_sym/config/* -/democracy_sym/simulation_output/* -/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 -simulation_output/ \ No newline at end of file +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 index 365f24a..2e4e506 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -2,7 +2,7 @@ model: # Elections election_costs: 1 - max_reward: 50 + max_reward: 1 election_impact_on_mutation: 1.8 mu: 0.05 rule_idx: 1 @@ -44,6 +44,3 @@ simulation: 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 - -output: - directory: "simulation_output" \ No newline at end of file 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 index f32965f..b6d292d 100644 --- a/configs/toy.yaml +++ b/configs/toy.yaml @@ -2,7 +2,7 @@ model: # Elections election_costs: 1 - max_reward: 50 + max_reward: 2 election_impact_on_mutation: 1.8 mu: 0.05 rule_idx: 1 @@ -10,7 +10,7 @@ model: # Model parameters num_agents: 5 - common_assets: 500 + common_assets: 5000 num_colors: 3 color_patches_steps: 3 patch_power: 1.0 @@ -33,7 +33,7 @@ model: # Visualization parameters visualization: # Grid parameters - cell_size: 10 + cell_size: 30 draw_borders: true # Statistics and Views show_area_stats: true @@ -42,8 +42,5 @@ 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 - -output: - directory: "simulation_output" \ No newline at end of file + 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/docs/technical/agents.md b/docs/technical/agents.md deleted file mode 100644 index a230069..0000000 --- a/docs/technical/agents.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agents - -Agents represent autonomous decision-making entities participating in grid-based simulations. -They operate under defined constraints (e.g., budgets, limited knowledge) -and use machine-learned strategies to optimize their outcomes. - ---- - -### **VoteAgent Class** - -Defined in: `participation_agent.py` - -#### **Key Attributes** -- **`unique_id`**: An identifier for the agent. -- **`personality`**: A numpy array representing the agent's preferences among colors. -- **`assets`**: Represents personal resources or motivation; consumed when participating in elections. -- **`confidence`**: Confidence in estimating the true color distribution. -- **`known_cells`**: Knowledge about a number of cells. - -#### **Key Methods** -1. **`ask_for_participation(area)`** - - Decides whether to participate in a given area's election. - - Returns `True` or `False`. - - ```python - # TODO - ``` - -2. **`decide_altruism_factor`** - - Uses a trained decision tree to determine the altruism factor for voting. - - ```python - # TODO - ``` - -3. **`update_known_cells(area)`** - - Updates the known cells by sampling the provided area. - -4. **`vote(area)`** - - Calculates the agent's preference ranking vector for the given area. - 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/architecture_overview.md b/docs/technical/architecture_overview.md deleted file mode 100644 index 5f39343..0000000 --- a/docs/technical/architecture_overview.md +++ /dev/null @@ -1,130 +0,0 @@ -# Project Summary - -DemocracySim explores how voting rules impact participation and welfare -in an evolving environment that is influenced by agents' group decisions. -The framework enables **dynamic simulations** where agents interact with their environment -and adapt their strategies to maximize rewards. - ---- - -### Core Components - -#### **Grid-Based Mesa Model** -- A grid-based world containing cells, which represent states with colors (e.g., `white`, `red`, `green`, `blue`). -- Elections within *areas* of the grid influence color transitions. -- Wrap-around boundaries avoid border effects. - -## `ColorCell` Class Documentation - - ::: path.to.your.module.ColorCell - - -### Attributes - -- **`color`**: - - **Type**: `Color` - - **Description**: The `color` attribute defines the current color of the `ColorCell`. A `Color` object may represent RGB values, color names, or any other color representation. - - **Default Value**: `None` (or specify the default if applicable). - - **Purpose**: Used to identify and differentiate cells by their color. - -### Methods - -- **`setColor(color: Color)`**: Sets the color of the `ColorCell` to the specified value. -- **`getColor() -> Color`**: Returns the current color of the `ColorCell`. - ---- - -For additional details on the `Color` class, see the [Testlink](#Attributes). - - -#### **Agents** -- Decision-making units equipped with: - - **Personality vectors**: Represent color preferences. - - **Assets**: Manage a budget when making decisions. - - **Decision logic**: Participate in elections or not, cast their vote strategically. - -#### **Elections** -- Take place inside instances of the `Area` class. -- Options to vote upon represent color rank vectors. -- Are held periodically in *areas* and or globally. -- Outcomes: - - Influence the color mutation of cells in the *area*. - - Determine rewards for all agents in the area. - - -#### 4. **Reward Mechanisms** -- Rewards depend on: - - Proximity to the objective "truth" (closeness of the decided color ranking - to the "real" color frequency distribution within the area). - - Distributed according to both egalitarian and preference-based weighting. - ---- - -### Class Overview of the Environment - -```mermaid -classDiagram - class ParticipationModel { - + Grid grid - + List[Area] areas - + List[VoteAgent] agents - + int colors - + int election_costs - + numpy.ndarray options - + Function voting_rule - + Function distance_func - + step() - «static» + pers_dist(size) - «static» + create_all_options(num_colors) - «static» + create_all_options(color_distribution) - } - - class ColorCell { - + int unique_id - + int, int position - + int color - + bool is_border_cell - - List[VoteAgent] agents - } - - class Area { - + int unique_id - + List[ColorCell] cells - + numpy.ndarray color_distribution - + List[VoteAgent] agents - + numpy.ndarray personality_distribution - + int, int _idx_field - - int _width - - int _height - + numpy.ndarray voted_ordering - + int voter_turnout - + float dist_to_reality - - conduct_election() - - tally_votes() - - distribute_rewards() - + step() - } - - class VoteAgent { - + int unique_id - + int, int position - + int assets - + numpy.ndarray personality - + List[Optional[ColorCell]] known_cells - + float confidence - + int num_elections_participated - + ask_for_participation(area) - + estimate_real_distribution(area) - + decide_altruism_factor(area) - + compute_assumed_opt_dist(area) - + vote(area) - } - - - ParticipationModel --> "1..*" VoteAgent - ParticipationModel --> "1..*" Area - ParticipationModel --> "1..*" ColorCell - Area --> "1..*" ColorCell - VoteAgent --> "*..1" ColorCell - Area --> "1..*" VoteAgent -``` \ No newline at end of file diff --git a/docs/technical/areas.md b/docs/technical/areas.md deleted file mode 100644 index a1cc548..0000000 --- a/docs/technical/areas.md +++ /dev/null @@ -1,32 +0,0 @@ -# Simulation Environment - -The environment in DemocracySim is structured as a grid, where elections and agent interactions occur. - ---- - -### Features -1. **Dynamic Environment**: - - The grid is composed of cells, each representing a state with a specific color. - - Elections in areas of the grid drive state transitions. - -2. **Mutations and Elections**: - - Mutations are applied to the grid, introducing randomness. - - Voting outcomes reflect agent personalities and decision-making. - ---- - -#### Area Workflow - -```mermaid -sequenceDiagram - participant Area - participant Election - participant Agent - Area->>Election: Area holds an election - Area->>Agent: Updates each agents knowledge - Agent->>Area: Has knowledge - Agent->>Election: Participate in election - Election->>Agent: Receive reward - Agent->>Agent: Update assets and strategies -``` - diff --git a/docs/technical/installation_instructions.md b/docs/technical/installation_instructions.md deleted file mode 100644 index e832fa3..0000000 --- a/docs/technical/installation_instructions.md +++ /dev/null @@ -1 +0,0 @@ -# ToDo 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 bd7228e..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). 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/scripts/headless_replay_README.md b/scripts/headless_replay_README.md deleted file mode 100644 index 83538a8..0000000 --- a/scripts/headless_replay_README.md +++ /dev/null @@ -1,101 +0,0 @@ -# Headless Simulation and Replay Guide for DemocracySim - -This guide explains how to run DemocracySim simulations in headless mode (without visualization) -and then replay them using Mesa's visualization tools. - -## Overview - -DemocracySim supports two main modes of operation: - -1. **Interactive Mode**: Run the simulation with real-time visualization using `run.py` -2. **Headless Mode**: Run the simulation without visualization using `run_headless.py`, saving the results to CSV files -3. **Replay Mode**: Visualize previously saved simulation data using `run_replay.py` - -This workflow allows you to: -- Run computationally intensive simulations without the overhead of visualization -- Save simulation results for later analysis -- Replay simulations to visualize the dynamics and outcomes - -## Running a Headless Simulation - -To run a simulation in headless mode: - -```bash -cd src -python run_headless.py -``` - -By default, this will: -1. Load configuration from `config/config.yaml` -2. Run the simulation for the specified number of steps -3. Save the results to CSV files in the `simulation_output` directory - -### Command-line Options - -You can specify a custom configuration file: - -```bash -python run_headless.py --configs path/to/your/configs.yaml -``` - -### Output Files - -The headless simulation produces two main output files: - -1. `model_data.csv`: Contains model-level data for each step, including: - - Collective assets - - Gini Index - - Voter turnout - - Color distributions - - Grid state (as a serialized list of lists) - -2. `agent_data.csv`: Contains agent-level data for each step - -## Replaying a Simulation - -To replay a previously saved simulation: - -```bash -cd src -python run_replay.py -``` - -This will: -1. Load the simulation data from `simulation_output/model_data.csv` -2. Launch a Mesa server with the same visualization elements as the interactive mode -3. Allow you to step through the simulation or play it automatically - -The replay will look identical to running the simulation via `run.py`, but instead of computing the simulation dynamics in real-time, it's loading the pre-computed state from the CSV file. - -### How Replay Works - -The replay functionality works by: -1. Loading the fixed parameters from `config/config.yaml` -2. Loading the dynamic state data from `model_data.csv` -3. Creating a special `ReplayParticipationModel` that inherits from the regular `ParticipationModel` -4. Overriding the `step()` method to update the model state from the saved data instead of computing it -5. Using the same visualization elements as the interactive mode - -## Troubleshooting - -If you encounter issues with the replay: - -1. **Missing GridColors column**: Ensure your `model_data.csv` file includes a `GridColors` column. This column contains the serialized grid state needed for visualization. - -2. **Visualization differences**: If the replay looks different from the interactive mode, check that your visualization elements are compatible with the replay model. - -3. **File not found errors**: Make sure the paths to the CSV files are correct. By default, they should be in the `simulation_output` directory. - -## Advanced Usage - -### Custom Data Collection - -If you want to collect additional data during the headless simulation, you can modify the `initialize_datacollector` method in `participation_model.py` to include additional model reporters or agent reporters. - -### Custom Replay Visualization - -If you want to customize the replay visualization, you can modify `run_replay.py` to include different visualization elements or to change the appearance of the existing elements. - -### Parameter Sweeps - -For running multiple simulations with different parameters, consider using `parameter_sweep.py` which can run multiple headless simulations and analyze the results. \ No newline at end of file diff --git a/scripts/replay_stubs.py b/scripts/replay_stubs.py deleted file mode 100644 index b69c658..0000000 --- a/scripts/replay_stubs.py +++ /dev/null @@ -1,18 +0,0 @@ -import mesa - - -class StubVoteAgent(mesa.Agent): - """Only the fields the browser portrayal uses.""" - def __init__(self, uid, model, pos, personality_idx): - super().__init__(uid, model) - self.pos = pos - self.personality_idx = personality_idx # or what you draw - - -class StubArea(mesa.Agent): - """Drawn as a rectangle – lives once in the south-west corner cell.""" - def __init__(self, uid, model, pos, h, w): - super().__init__(uid, model) - self.pos = pos - self.height = h - self.width = w diff --git a/scripts/run.py b/scripts/run.py index cc1fefb..f848fb2 100644 --- a/scripts/run.py +++ b/scripts/run.py @@ -1,52 +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.participation_model import ParticipationModel -from src.model_setup import ( - model_params as params, - canvas_element, - voter_turnout, - wealth_chart, - color_distribution_chart, -) -from src.utils.visualisation_elements import ( - PersonalityDistribution, - AreaStats, - VoterTurnoutElement, - AreaPersonalityDists, -) +from src.config.loader import load_config +from src.model_setup import make_server -class CustomModularServer(ModularServer): - """Prevents double initialization of the model.""" - 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 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() - def reset_model(self): - if not self.initialized: - self.initialized = True - return - 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, -) + cfg = load_config(args.config) + server: ModularServer = make_server(cfg) + server.launch(open_browser=not args.no_browser) if __name__ == "__main__": - server.launch(open_browser=True) + main() 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/src/agents/participation_agent.py b/src/agents/vote_agent.py similarity index 64% rename from src/agents/participation_agent.py rename to src/agents/vote_agent.py index 25fdd6a..2cfa77b 100644 --- a/src/agents/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 src.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,8 +41,8 @@ class VoteAgent(Agent): can decide to use them to participate in elections. """ - def __init__(self, unique_id, model, pos, personality=None, - personality_idx=None, 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: @@ -78,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 @@ -106,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) @@ -128,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 @@ -142,21 +145,23 @@ 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: """ - # 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 as required | also think about making classes for orders and dists to not confuse them and have it set up correctly and well documented 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 to vote for or not). It takes "altruistic" concepts into consideration. @@ -165,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 @@ -175,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. @@ -204,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 @@ -220,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 index c6f94cb..957a96d 100644 --- a/src/model_setup.py +++ b/src/model_setup.py @@ -1,200 +1,213 @@ """ -This file handles the definition of the canvas and model parameters. +Wires config -> ParticipationModel kwargs -> Mesa UI server. """ -from typing import TYPE_CHECKING, cast -from mesa.visualization.modules import ChartModule -from src.agents.participation_agent import ColorCell -from src.participation_model import ( - ParticipationModel, distance_functions, social_welfare_functions -) +from src.config.schema import AppConfig, ModelConfig from math import factorial -from pathlib import Path import mesa -import yaml -import os - - -def load_config(config_file=None): - if config_file is None: - config_file = os.environ.get("CONFIG_FILE", "config.yaml") - config_path = Path(__file__).parent.parent / 'configs' / config_file - with open(config_path, 'r') as f: - conf = yaml.safe_load(f) - return conf - +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 -# Load config -config = load_config() -cfg = config["model"] -vis_cfg = config.get("visualization", {}) +# 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", +} -# Colors -_COLORS = [ - "White", "Red", "Green", "Blue", "Yellow", "Aqua", "Fuchsia", - "Lime", "Maroon", "Orange" -] # 10 colors -def participation_draw(cell: ColorCell): +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). """ - This function is registered with the visualization server to be called - each tick to indicate how to draw the cell in its current color. + return {k: getattr(model_cfg, k) for k in _ALLOWED_KW if + hasattr(model_cfg, k)} - Args: - cell: The cell in the simulation - Returns: - The portrayal dictionary. +def build_model_params(model_cfg: AppConfig) -> dict: + """ + Create Mesa UI sliders/params so the web UI shows controls. """ - if cell is None: - raise AssertionError - color = _COLORS[cell.color] - draw_borders = vis_cfg.get("draw_borders", True) - 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: - cell.model = cast(ParticipationModel, cell.model) - if cell.is_border_cell and draw_borders: - portrayal["Shape"] = "circle" - portrayal["r"] = 0.9 - 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] - # Print number of agents in the cell if there are any - if cell.num_agents_in_cell > 0: - portrayal["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 + height = model_cfg.height + width = model_cfg.width + num_colors = model_cfg.num_colors + num_agents = model_cfg.num_agents -canvas_element = mesa.visualization.CanvasGrid( - participation_draw, - cfg["width"], - cfg["height"], - cfg["width"] * vis_cfg["cell_size"], - cfg["height"] * vis_cfg["cell_size"] -) + 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 -wealth_chart = ChartModule( - [{"Label": "Collective assets", "Color": "Black"}], - data_collector_name='datacollector' -) -color_distribution_chart = ChartModule( - [{"Label": f"Color {i}", - "Color": "LightGrey" if _COLORS[i] == "White" else _COLORS[i]} - for i in range(len(_COLORS))], - data_collector_name='datacollector' -) +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) -voter_turnout = ChartModule( - [{"Label": "Voter turnout globally (in percent)", "Color": "Black"}, - {"Label": "Gini Index (0-100)", "Color": "Red"}], - data_collector_name='datacollector' -) -visualization_params = { - "draw_borders": mesa.visualization.Checkbox( - name="Draw border cells", value=vis_cfg.get("draw_borders", True) - ), - "show_area_stats": mesa.visualization.Checkbox( - name="Show all statistics", value=cfg.get("show_area_stats", True) - ), -} +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") -model_params = { - "height": cfg["height"], - "width": cfg["width"], - "rule_idx": mesa.visualization.Slider( - name=f"Rule index {[r.__name__ for r in social_welfare_functions]}", - value=cfg["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=cfg["distance_idx"], min_value=0, max_value=len(distance_functions)-1, - ), - "election_costs": mesa.visualization.Slider( - name="Election costs", value=cfg["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=cfg["max_reward"], min_value=0, - max_value=cfg["election_costs"]*100, - step=1, description="The costs for participating in an election" - ), - "mu": mesa.visualization.Slider( - name="Mutation rate", value=cfg["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=cfg["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=cfg["num_agents"], min_value=10, max_value=99999, - step=10 - ), - "num_colors": mesa.visualization.Slider( - name="# Colors", value=cfg["num_colors"], min_value=2, max_value=len(_COLORS), - step=1 - ), - "num_personalities": mesa.visualization.Slider( - name="# different personalities", value=cfg["num_personalities"], - min_value=1, max_value=factorial(cfg["num_colors"]), step=1 - ), - "common_assets": mesa.visualization.Slider( - name="Initial common assets", value=cfg["common_assets"], - min_value=cfg["num_agents"], max_value=1000*cfg["num_agents"], step=10 - ), - "known_cells": mesa.visualization.Slider( - name="# known fields", value=cfg["known_cells"], - min_value=1, max_value=100, step=1 - ), - "color_patches_steps": mesa.visualization.Slider( - name="Patches size (# steps)", value=cfg["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=cfg["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=cfg["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 {cfg['height']}x{cfg['width']} world", step=1, - value=cfg["num_areas"], min_value=1, max_value=min(cfg["width"], cfg["height"])//2 - ), - "av_area_height": mesa.visualization.Slider( - name="Av. area height", value=cfg["av_area_height"], - min_value=2, max_value=cfg["height"]//2, - step=1, description="Select the average height of an area" - ), - "av_area_width": mesa.visualization.Slider( - name="Av. area width", value=cfg["av_area_width"], - min_value=2, max_value=cfg["width"]//2, - step=1, description="Select the average width of an area" - ), - "area_size_variance": mesa.visualization.Slider( - name="Area size variance", value=cfg["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" - ), -} + # 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/src/participation_model.py b/src/models/participation_model.py similarity index 53% rename from src/participation_model.py rename to src/models/participation_model.py index c6adc6c..6c3ae95 100644 --- a/src/participation_model.py +++ b/src/models/participation_model.py @@ -1,10 +1,14 @@ -from typing import TYPE_CHECKING, cast, List, Optional +from typing import TYPE_CHECKING, cast, List, Optional, Callable import mesa -from src.agents.participation_agent import VoteAgent, ColorCell +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 -import numpy as np +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] @@ -12,465 +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 - 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 = 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 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_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): """ @@ -556,8 +101,14 @@ 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, heterogeneity, - rule_idx, distance_idx, election_costs, max_reward): + 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 @@ -615,30 +166,30 @@ def __init__(self, height, width, num_agents, num_colors, num_personalities, self.datacollector.collect(self) @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, id_start=0): + def _initialize_color_cells(self, id_start=0) -> None: """ Initialize one ColorCell per grid cell. Args: @@ -659,7 +210,7 @@ def _initialize_color_cells(self, id_start=0): # And to the 'model.color_cells' list (for faster access) 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, id_start=0): + 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. @@ -669,7 +220,6 @@ def initialize_voting_agents(self, id_start=0): 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 idx in range(self.num_agents): # Assign unique ID after areas @@ -678,7 +228,8 @@ def initialize_voting_agents(self, id_start=0): x = self.random.randrange(self.width) y = self.random.randrange(self.height) # Choose a personality based on the distribution - personality_idx = rng.choice(len(self.personalities), p=dist) + 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(unique_id, self, (x, y), personality, @@ -690,7 +241,7 @@ def initialize_voting_agents(self, id_start=0): 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. @@ -703,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. """ @@ -730,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`, @@ -775,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)). @@ -789,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. @@ -810,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.") @@ -824,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( @@ -884,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. @@ -900,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 @@ -954,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. @@ -963,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 @@ -976,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. @@ -987,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 @@ -999,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 @@ -1017,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: @@ -1028,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/distance_functions.py b/src/utils/distance_functions.py index 90af2fa..0c4abc2 100644 --- a/src/utils/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/src/utils/social_welfare_functions.py b/src/utils/social_welfare_functions.py index ff933f1..d89a669 100644 --- a/src/utils/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/src/utils/visualisation_elements.py b/src/viz/visualisation_elements.py similarity index 73% rename from src/utils/visualisation_elements.py rename to src/viz/visualisation_elements.py index 2e938b6..ea5b644 100644 --- a/src/utils/visualisation_elements.py +++ b/src/viz/visualisation_elements.py @@ -1,14 +1,15 @@ import matplotlib.pyplot as plt from mesa.visualization import TextElement import matplotlib.patches as patches -from src.model_setup import _COLORS, vis_cfg +from src.viz.factory import COLORS, get_vis_cfg import base64 import math import io -_COLORS[0] = "LightGray" -# Visualization config -show_area_stats = vis_cfg.get("show_area_stats", True) +# 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() @@ -22,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 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') @@ -73,35 +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): - # 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) @@ -112,12 +104,10 @@ def create_once(self, model): ax.set_xlabel('"Personality" ID') 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 @@ -125,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 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: @@ -153,75 +139,68 @@ 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 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): - 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, num_areas), sharex=True) - for ax, area in zip(axes.flatten(), model.areas): - # Fetch data + 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') - # Set the top of all bars to the color code of the personality max_height = max(heights) if heights else 1 - p_top_hight = max_height * 0.02 # Top 2% colored acc. to personality + 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) @@ -237,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 eedeeb5..b444326 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -1,4 +1,4 @@ -from src.participation_model import ParticipationModel +from src.models.participation_model import ParticipationModel from pathlib import Path import yaml 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_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 02b0006..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 src.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_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_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 04d0fe8..3b1fcb9 100644 --- a/tests/test_participation_area_agent.py +++ b/tests/test_participation_area_agent.py @@ -1,8 +1,8 @@ import unittest import random import numpy as np -from src.participation_model import Area -from src.agents.participation_agent import VoteAgent +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 diff --git a/tests/test_participation_model.py b/tests/test_participation_model.py index a227969..6a697dc 100644 --- a/tests/test_participation_model.py +++ b/tests/test_participation_model.py @@ -1,12 +1,14 @@ import unittest -from src.participation_model import (ParticipationModel, Area, - distance_functions, - social_welfare_functions) -from src.model_setup import config +from src.models.participation_model import (ParticipationModel, Area, + distance_functions, + social_welfare_functions) +from src.config.loader import load_config import mesa -model_cfg = config["model"] -vis_cfg = config.get("visualization", {}) +config = load_config() +model_cfg = config.model.model_dump() +vis_cfg = config.visualization.model_dump() + class TestParticipationModel(unittest.TestCase): diff --git a/tests/test_participation_voting_agent.py b/tests/test_participation_voting_agent.py index 2716007..8a3888b 100644 --- a/tests/test_participation_voting_agent.py +++ b/tests/test_participation_voting_agent.py @@ -1,13 +1,15 @@ from .test_participation_model import TestParticipationModel import unittest -from src.participation_model import Area -from src.agents.participation_agent import VoteAgent, combine_and_normalize +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 -from src.model_setup import config -model_cfg = config["model"] -vis_cfg = config.get("visualization", {}) + +config = load_config() +model_cfg = config.model +vis_cfg = config.visualization class TestVotingAgent(unittest.TestCase): @@ -16,7 +18,7 @@ def setUp(self): test_model.setUp() self.model = test_model.model personality = random.choice(self.model.personalities) - self.agent = VoteAgent(model_cfg["num_agents"] + 1, self.model, + 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, @@ -55,12 +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: - # TODO: This test fails sometimes (11.09.25) - self.assertEqual(list(comb), list(est_dist)) # <--- here + 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_utility_functions.py b/tests/test_utility_functions.py index 7e2c1cc..fd5b349 100644 --- a/tests/test_utility_functions.py +++ b/tests/test_utility_functions.py @@ -1,6 +1,6 @@ import unittest import numpy as np -from src.agents.participation_agent import combine_and_normalize +from src.agents.vote_agent import combine_and_normalize class TestUtilityFunctions(unittest.TestCase): """Test utility functions in the src package."""