Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [ main ]

permissions:
contents: read

jobs:
build:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ annotated-types = ">=0.6.0"
typing-inspection = ">=0.4.0"
PyYAML = "6.0.3"
toml = "0.10.2"
tornado = "6.4"
tornado = ">=6.5.2,<7"
pytest = "8.2.0"
pytest-cov = "5.0.0"
iniconfig = "*"
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ annotated-types>=0.6.0
typing-inspection>=0.4.0
PyYAML==6.0.3
toml==0.10.2
tornado==6.4
tornado>=6.5.2
pytest==8.2.0
pytest-cov==5.0.0
iniconfig
Expand Down
3 changes: 3 additions & 0 deletions src/agents/area.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ def add_agent(self, agent: VoteAgent) -> None:
Args:
agent (VoteAgent): The agent to be added to the area.
"""
# Make sure its an instance of Agent
if not isinstance(agent, Agent):
raise ValueError("Only VoteAgent instances can be added to an Area")
self.agents.append(agent)

def add_cell(self, cell: ColorCell) -> None:
Expand Down
27 changes: 12 additions & 15 deletions src/agents/color_cell.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from mesa import Agent
from mesa import Agent, Model


class ColorCell(Agent):
Expand All @@ -9,7 +9,7 @@ class ColorCell(Agent):
color (int): The color of the cell.
"""

def __init__(self, unique_id, model, pos: tuple, initial_color: int):
def __init__(self, unique_id: int, model: Model, pos: tuple, initial_color: int):
"""
Initializes a ColorCell, at the given row, col position.

Expand All @@ -20,33 +20,30 @@ def __init__(self, unique_id, model, pos: tuple, initial_color: int):
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.pos will be set by the grid when we place the agent
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.agents = [] # TODO change to using mesas AgentSet class!
self.areas = [] # TODO change to using mesas AgentSet class!
self.is_border_cell = False
# Add it to the models grid
model.grid.place_agent(self, pos)

def __str__(self):
return (f"Cell ({self.unique_id}, pos={self.position}, "
return (f"Cell ({self.unique_id}, pos={self.pos}, "
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
return self.pos[0]

@property
def position(self): # The variable pos is special in mesa!
"""The location of this cell."""
return self._row, self._col
def col(self):
"""The col location of this cell."""
return self.pos[1]

@property
def num_agents_in_cell(self):
Expand Down
5 changes: 4 additions & 1 deletion src/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ def check_schema(open_file):
return AppConfig.model_validate(raw)


def load_config(config_file=None):
def load_config(config_file=None) -> AppConfig:
"""
Load configuration from a YAML file.

Returns:
AppConfig: Validated configuration object.
"""
if config_file is None:
config_file = os.environ.get("CONFIG_FILE", "default.yaml")
Expand Down
31 changes: 19 additions & 12 deletions src/models/participation_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ class ParticipationModel(mesa.Model):
Attributes:
grid (mesa.space.SingleGrid): Grid representing the environment
with a single occupancy per cell (the color).
height (int): The height of the grid.
width (int): The width of the grid.
grid.height (int): The height of the grid.
grid.width (int): The width of the grid.
colors (ndarray): Array containing the unique color identifiers.
voting_rule (Callable): A function defining the social welfare
function to aggregate agent preferences. This callable typically
Expand Down Expand Up @@ -110,8 +110,6 @@ def __init__(self, height, width, num_agents, num_colors, num_personalities,
else:
self.np_random = np.random.default_rng()
# TODO clean up class (public/private variables)
self.height = height
self.width = width
self.colors = np.arange(num_colors)
# Create a scheduler that goes through areas first then color cells
self.scheduler = CustomScheduler(self)
Expand All @@ -138,21 +136,21 @@ def __init__(self, height, width, num_agents, num_colors, num_personalities,
# Election impact factor on color mutation through a probability array
self.color_probs = self.init_color_probs(election_impact_on_mutation)
# Create search pairs once for faster iterations when comparing rankings
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.search_pairs = list(combinations(range(0, self.options.shape[0]), 2)) # TODO check if correct!
self.option_vec = np.arange(self.options.shape[0]) # Also to speed up
self.color_search_pairs = list(combinations(range(0, num_colors), 2))
# Create color cells (IDs start after areas+agents)
self.color_cells: List[Optional[ColorCell]] = [None] * (height * width)
self.color_cells: List[Optional[ColorCell]] = [None] * (height * width) # TODO change to using mesas AgentSet class!
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.voting_agents: List[Optional[VoteAgent]] = [None] * num_agents # TODO change to using mesas AgentSet class!
self.personalities = self.create_personalities(num_personalities)
self.personality_distribution = self.pers_dist(num_personalities)
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
self.areas: List[Optional[Area]] = [None] * num_areas # TODO change to using mesas AgentSet class!
self.av_area_height = av_area_height
self.av_area_width = av_area_width
self.area_size_variance = area_size_variance
Expand All @@ -165,6 +163,14 @@ def __init__(self, height, width, num_agents, num_colors, num_personalities,
# Collect initial data
self.datacollector.collect(self)

@property
def height(self) -> int:
return self.grid.height

@property
def width(self) -> int:
return self.grid.width

@property
def num_colors(self) -> int:
return len(self.colors)
Expand Down Expand Up @@ -203,12 +209,10 @@ def _initialize_color_cells(self, id_start=0) -> None:
color = self.color_by_dst(self._preset_color_dst)
# 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[idx] = 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: change to using the grid

def initialize_voting_agents(self, id_start=0) -> None:
"""
Expand All @@ -219,6 +223,9 @@ def initialize_voting_agents(self, id_start=0) -> None:
Args:
id_start (int): The starting ID for agents to ensure unique IDs.
"""
# Testing parameter validity
if self.num_agents < 1:
raise ValueError("The number of agents must be at least 1.")
dist = self.personality_distribution
assets = self.common_assets // self.num_agents
for idx in range(self.num_agents):
Expand Down
2 changes: 1 addition & 1 deletion src/viz/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def portrayal(agent):
"Layer": 0,
"Color": color_name,
# Hover fields:
"Position": f"{agent.position}",
"Position": f"{agent.pos}",
"Color - text": color_name,
}

Expand Down
27 changes: 19 additions & 8 deletions tests/factory.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
from src.models.participation_model import ParticipationModel
from pathlib import Path
import yaml
from src.model_setup import build_model_kwargs
from src.config.loader import load_config
from src.config.schema import AppConfig

DEFAULT_CONFIG = Path("configs/default.yaml")

def create_default_model(**overrides):
with open(DEFAULT_CONFIG, "r") as f:
config = yaml.safe_load(f)
params = config["model"]
def create_test_model(**overrides) -> tuple[ParticipationModel, dict]:
"""
Create a ParticipationModel instance using the default config
(set by DEFAULT_CONFIG, fallback to 'configs/default.yaml'),
returning both the model and the parameter dictionary used.
This is useful for tests that need to inspect model parameters.

Args:
**overrides: Any model parameters to override from defaults.
Returns:
tuple: (ParticipationModel instance, config dict)
"""
model_app_cfg = load_config().model
params = build_model_kwargs(model_app_cfg)
params.update(overrides)
return ParticipationModel(**params)
model = ParticipationModel(**params)
return model, params
70 changes: 70 additions & 0 deletions tests/test_color_cell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import unittest
from mesa import Model, space
from src.agents.color_cell import ColorCell


class DummyModel(Model):
"""Minimal model so ColorCell can be instantiated."""
def __init__(self):
super().__init__()
self.grid = space.SingleGrid(10, 10, torus=True)


class TestColorCell(unittest.TestCase):

def setUp(self):
self.model = DummyModel()

def test_initialization(self):
cell = ColorCell(unique_id=1, model=self.model, pos=(2, 3), initial_color=5)

self.assertEqual(cell.unique_id, 1)
self.assertEqual(cell.row, 2)
self.assertEqual(cell.col, 3)
self.assertEqual(cell.pos, (2, 3))
self.assertEqual(cell.color, 5)
self.assertEqual(cell.num_agents_in_cell, 0)
self.assertEqual(cell.agents, [])
self.assertEqual(cell.areas, [])
self.assertFalse(cell.is_border_cell)

def test_add_and_remove_agents(self):
cell = ColorCell(1, self.model, (0, 0), 1)
agent1, agent2 = object(), object()

cell.add_agent(agent1)
cell.add_agent(agent2)

self.assertEqual(cell.num_agents_in_cell, 2)
self.assertIn(agent1, cell.agents)
self.assertIn(agent2, cell.agents)

cell.remove_agent(agent1)
self.assertEqual(cell.num_agents_in_cell, 1)
self.assertNotIn(agent1, cell.agents)

def test_add_area(self):
cell = ColorCell(1, self.model, (0, 0), 1)
area = {"id": 123}

cell.add_area(area)
self.assertIn(area, cell.areas)

def test_str_representation(self):
cell = ColorCell(42, self.model, (1, 1), 7)
s = str(cell)
self.assertIn("Cell (42", s)
self.assertIn("pos=(1, 1)", s)
self.assertIn("color=7", s)
self.assertIn("num_agents=0", s)

# TODO: Add tests for color_step once implemented
# - Check that it picks a next color from neighbors
# - Handle ties correctly
#
# TODO: Add tests for advance once implemented
# - Ensure that color updates from _next_color


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions tests/test_conduct_election.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import unittest
from unittest.mock import MagicMock
from tests.factory import create_default_model
from tests.factory import create_test_model

# TODO add more complex tests

class TestConductElection(unittest.TestCase):
def setUp(self):
self.model = create_default_model(num_areas=1)
self.model, _ = create_test_model(num_areas=1)
self.model.initialize_area = MagicMock()

def test_election_returns_integer_turnout(self):
Expand Down
4 changes: 2 additions & 2 deletions tests/test_create_personalities.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import unittest
from math import factorial
from itertools import permutations
from tests.factory import create_default_model
from tests.factory import create_test_model
from unittest.mock import MagicMock


class TestParticipationModel(unittest.TestCase):

def setUp(self):
"""Create a fresh model instance before each test and mock `initialize_area`."""
self.model = create_default_model(
self.model, _ = create_test_model(
height=10, width=10, num_agents=100, num_colors=4,
num_personalities=10, area_size_variance=0.2,
num_areas=4, av_area_height=5, av_area_width=5,
Expand Down
4 changes: 2 additions & 2 deletions tests/test_distribute_rewards.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import unittest
from unittest.mock import MagicMock
from tests.factory import create_default_model
from tests.factory import create_test_model

class TestDistributeRewards(unittest.TestCase):
def setUp(self):
self.model = create_default_model(num_areas=1)
self.model, _ = create_test_model(num_areas=1)
self.model.initialize_area = MagicMock()

def test_distribute(self):
Expand Down
Loading