From 0142b18a075060bd0732e2f5ce04aab26960bb3d Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Fri, 27 Dec 2024 11:15:26 +0530 Subject: [PATCH 01/13] added a new example: city walking behaviour --- examples/city_walking_behaviour/Readme.md | 24 + examples/city_walking_behaviour/app.py | 300 +++++++++ .../city_walking_behaviour/agents.py | 413 ++++++++++++ .../city_walking_behaviour/model.py | 633 ++++++++++++++++++ 4 files changed, 1370 insertions(+) create mode 100644 examples/city_walking_behaviour/Readme.md create mode 100644 examples/city_walking_behaviour/app.py create mode 100644 examples/city_walking_behaviour/city_walking_behaviour/agents.py create mode 100644 examples/city_walking_behaviour/city_walking_behaviour/model.py diff --git a/examples/city_walking_behaviour/Readme.md b/examples/city_walking_behaviour/Readme.md new file mode 100644 index 00000000..efb2e591 --- /dev/null +++ b/examples/city_walking_behaviour/Readme.md @@ -0,0 +1,24 @@ +# Walking Behavior Agent-Based Model + +This repository contains an agent-based model (ABM) that simulates walking behavior in a hypothetical city, examining how socioeconomic status (SES), built environment, and social factors influence walking patterns. + +This ABM investigates spatial determinants of social inequalities in walking behavior by modeling dynamic interactions between individual attributes and environmental factors. The model incorporates feedback mechanisms and individual-environment interactions to simulate realistic walking patterns across different socioeconomic groups. + +## How to Run + +To run a basic simulation: + +```python + +solara run app.py + +``` + +# Files + +- [city_walking_behaviour/model.py](city_walking_behaviour/model.py): Core model file. +- [city_walking_behaviour/agents.py](city_walking_behaviour/agents.py): The agent class. + +## Further Reading + +1. A Spatial Agent-Based Model for the Simulation of Adults’ Daily Walking Within a City [article](https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/) diff --git a/examples/city_walking_behaviour/app.py b/examples/city_walking_behaviour/app.py new file mode 100644 index 00000000..104c2ffd --- /dev/null +++ b/examples/city_walking_behaviour/app.py @@ -0,0 +1,300 @@ +from city_walking_behaviour.agents import Human, GroceryStore, SocialPlace, NonFoodShop, Other +from city_walking_behaviour.model import WalkingModel +from mesa.experimental.devs import ABMSimulator +from mesa.visualization import ( + SolaraViz, + make_space_component, + make_plot_component, +) + +# Define the scenarios +SCENARIOS = [ + ("random_random", "Random Land Use, Random Safety"), + ("random_safe", "Random Land Use, Low Safety in Core"), + ("central_random", "Centralized Land Use, Random Safety"), + ("central_safe", "Centralized Land Use, Low Safety in Core"), +] + + +def agent_portrayal(agent): + """Determine visual portrayal details for each agent.""" + if agent is None: + return + + portrayal = { + "size": 25, + } + + if isinstance(agent, GroceryStore): + portrayal["color"] = "tab:green" + portrayal["marker"] = "s" + portrayal["zorder"] = 2 + elif isinstance(agent, SocialPlace): + portrayal["color"] = "tab:purple" + portrayal["marker"] = "s" + portrayal["zorder"] = 2 + elif isinstance(agent, NonFoodShop): + portrayal["color"] = "tab:olive" + portrayal["marker"] = "s" + portrayal["zorder"] = 2 + elif isinstance(agent, Other): + portrayal["color"] = "tab:brown" + portrayal["marker"] = "s" + portrayal["zorder"] = 2 + elif isinstance(agent, Human): + portrayal["color"] = "tab:red" + portrayal["marker"] = "v" + portrayal["zorder"] = 2 + + return portrayal + + +model_params = { + "width": 40, + "height": 40, + "seed": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, + "no_of_couples": { + "type": "SliderInt", + "value": 2400, + "label": "Number of Couples:", + "min": 2000, + "max": 3000, + "step": 100, + }, + "no_of_singles": { + "type": "SliderInt", + "value": 600, + "label": "Number of Singles:", + "min": 300, + "max": 1000, + "step": 20, + }, + "no_of_grocery_stores": { + "type": "SliderInt", + "value": 10, + "label": "Number of Grocery Stores:", + "min": 5, + "max": 20, + "step": 1, + }, + "no_of_social_places": { + "type": "SliderInt", + "value": 75, + "label": "Number of Social Places:", + "min": 50, + "max": 90, + "step": 1, + }, + "no_of_non_food_shops": { + "type": "SliderInt", + "value": 40, + "label": "Number of Non-Food Shops:", + "min": 25, + "max": 55, + "step": 1, + }, + "no_of_others": { + "type": "SliderInt", + "value": 475, + "label": "Number of Other Places:", + "min": 405, + "max": 600, + "step": 1, + }, + "scenario": { + "type": "Select", + "value": "random_random", + "label": "Scenario", + "values": [s[0] for s in SCENARIOS], + }, +} + + +def post_process_space(ax): + """Ensure consistent scaling for visual grid.""" + ax.set_aspect("equal") + ax.set_xticks([]) + ax.set_yticks([]) + ax.get_figure().set_size_inches(10, 10) + + +def post_process_lines_walk(ax): + """Configure the average walking trips plot.""" + handles, labels = ax.get_legend_handles_labels() + new_labels = [] + for label in labels: + if label == "avg_walk_ses1": + new_labels.append("SES 1") + elif label == "avg_walk_ses2": + new_labels.append("SES 2") + elif label == "avg_walk_ses3": + new_labels.append("SES 3") + elif label == "avg_walk_ses4": + new_labels.append("SES 4") + elif label == "avg_walk_ses5": + new_labels.append("SES 5") + else: + new_labels.append(label) + ax.legend(handles, new_labels, loc="center left", bbox_to_anchor=(1, 0.9)) + ax.set_ylabel("Average Walking Trips", fontsize=12, fontweight="normal") + + +def post_process_lines_work(ax): + """Configure the work trips plot.""" + handles, labels = ax.get_legend_handles_labels() + new_labels = [] + for label in labels: + if label == "avg_work_ses1": + new_labels.append("SES 1") + elif label == "avg_work_ses2": + new_labels.append("SES 2") + elif label == "avg_work_ses3": + new_labels.append("SES 3") + elif label == "avg_work_ses4": + new_labels.append("SES 4") + elif label == "avg_work_ses5": + new_labels.append("SES 5") + else: + new_labels.append(label) + ax.legend(handles, new_labels, loc="center left", bbox_to_anchor=(1, 0.9)) + ax.set_ylabel("Average Work Trips", fontsize=12, fontweight="normal") + + +def post_process_lines_basic(ax): + """Configure the basic needs trips plot.""" + handles, labels = ax.get_legend_handles_labels() + new_labels = [] + for label in labels: + if label == "avg_basic_ses1": + new_labels.append("SES 1") + elif label == "avg_basic_ses2": + new_labels.append("SES 2") + elif label == "avg_basic_ses3": + new_labels.append("SES 3") + elif label == "avg_basic_ses4": + new_labels.append("SES 4") + elif label == "avg_basic_ses5": + new_labels.append("SES 5") + else: + new_labels.append(label) + ax.legend(handles, new_labels, loc="center left", bbox_to_anchor=(1, 0.9)) + ax.set_ylabel("Average Trips for Basic Needs", fontsize=12, fontweight="normal") + + +def post_process_lines_leisure(ax): + """Configure the leisure trips plot.""" + handles, labels = ax.get_legend_handles_labels() + new_labels = [] + for label in labels: + if label == "avg_leisure_ses1": + new_labels.append("SES 1") + elif label == "avg_leisure_ses2": + new_labels.append("SES 2") + elif label == "avg_leisure_ses3": + new_labels.append("SES 3") + elif label == "avg_leisure_ses4": + new_labels.append("SES 4") + elif label == "avg_leisure_ses5": + new_labels.append("SES 5") + else: + new_labels.append(label) + ax.legend(handles, new_labels, loc="center left", bbox_to_anchor=(1, 0.9)) + ax.set_ylabel("Average Trips for Leisure", fontsize=12, fontweight="normal") + + +def post_process_buildings_legend(ax): + import matplotlib.lines as mlines + # Create legend entries for each building/agent + grocery_store = mlines.Line2D([], [], color="tab:green", marker="s", linestyle="None", markersize=10, label="Grocery Store") + social_place = mlines.Line2D([], [], color="tab:purple", marker="s", linestyle="None", markersize=10, label="Social Place") + non_food_shop = mlines.Line2D([], [], color="tab:olive", marker="s", linestyle="None", markersize=10, label="Non-Food Shop") + other_building = mlines.Line2D([], [], color="tab:brown", marker="s", linestyle="None", markersize=10, label="Other") + human = mlines.Line2D([], [], color="tab:red", marker="v", linestyle="None", markersize=10, label="Human") + + ax.legend( + handles=[grocery_store, social_place, non_food_shop, other_building, human], + loc="center", + bbox_to_anchor=(0.5, 0.5) + ) + ax.axis("off") + +space_component = make_space_component( + agent_portrayal, draw_grid=True, post_process=post_process_space +) + +plot_component_a = make_plot_component( + { + "avg_walk_ses1": "tab:red", + "avg_walk_ses2": "tab:blue", + "avg_walk_ses3": "tab:green", + "avg_walk_ses4": "tab:purple", + "avg_walk_ses5": "tab:cyan", + }, + post_process=post_process_lines_walk, +) + +plot_component_b = make_plot_component( + { + "avg_work_ses1": "tab:red", + "avg_work_ses2": "tab:blue", + "avg_work_ses3": "tab:green", + "avg_work_ses4": "tab:purple", + "avg_work_ses5": "tab:cyan", + }, + post_process=post_process_lines_work, +) + +plot_component_c = make_plot_component( + { + "avg_basic_ses1": "tab:red", + "avg_basic_ses2": "tab:blue", + "avg_basic_ses3": "tab:green", + "avg_basic_ses4": "tab:purple", + "avg_basic_ses5": "tab:cyan", + }, + post_process=post_process_lines_basic, +) + +plot_component_d = make_plot_component( + { + "avg_leisure_ses1": "tab:red", + "avg_leisure_ses2": "tab:blue", + "avg_leisure_ses3": "tab:green", + "avg_leisure_ses4": "tab:purple", + "avg_leisure_ses5": "tab:cyan", + }, + post_process=post_process_lines_leisure, +) + +plot_component_legend = make_plot_component({}, post_process=post_process_buildings_legend) + +# Initialize and run the model +simulator = ABMSimulator() +model = WalkingModel(simulator=simulator) + +# server = mesa.visualization( +# WalkingModel, +# [space_component, plot_component_legend, plot_component_a, plot_component_b, plot_component_c, plot_component_d], +# "Walking Model", +# model_params, +# ) + +page = SolaraViz( + model, + components=[ + space_component, + plot_component_legend, + plot_component_a, + plot_component_b, + plot_component_c, + plot_component_d, + ], + model_params=model_params, + name="Walking Model", + simulator=simulator, +) +page diff --git a/examples/city_walking_behaviour/city_walking_behaviour/agents.py b/examples/city_walking_behaviour/city_walking_behaviour/agents.py new file mode 100644 index 00000000..344a33b6 --- /dev/null +++ b/examples/city_walking_behaviour/city_walking_behaviour/agents.py @@ -0,0 +1,413 @@ +from typing import Optional +from mesa import Model +import math +from mesa.agent import AgentSet +from mesa.experimental.cell_space import CellAgent, FixedAgent +from mesa.experimental.cell_space import Cell +from enum import Enum +import numpy as np + +# Constants for probability values +FEMALE_PROBABILITY = 0.5 +DOG_OWNER_PROBABILITY = 0.2 +SINGLE_HOUSEHOLD_PROBABILITY = 0.2 +MIN_AGE = 18 +MAX_AGE = 87 +MIN_FRIENDS = 3 +MAX_FRIENDS = 5 +WORKING_PROBABILITY = 0.95 +RETIREMENT_AGE = 69 + + +class ActivityType(Enum): + WORK = "work" + GROCERY = "grocery" + NON_FOOD_SHOPPING = "shopping" + SOCIAL = "social" + LEISURE = "leisure" + + +class Workplace: + """Generic workplace base class.""" + + def __init__(self, store_type: str): + self.store_type = store_type + + +class GroceryStore(Workplace, FixedAgent): + def __init__(self, model: Model, cell=None): + Workplace.__init__(self, store_type="Grocery Store") + FixedAgent.__init__(self, model) + self.cell = cell + + +class NonFoodShop(Workplace, FixedAgent): + def __init__(self, model: Model, cell=None): + Workplace.__init__(self, store_type="Non-Food Shop") + FixedAgent.__init__(self, model) + self.cell = cell + + +class SocialPlace(Workplace, FixedAgent): + def __init__(self, model: Model, cell=None): + Workplace.__init__(self, store_type="Social Place") + FixedAgent.__init__(self, model) + self.cell = cell + + +class Other(Workplace, FixedAgent): + def __init__(self, model: Model, cell=None): + Workplace.__init__(self, store_type="Other") + FixedAgent.__init__(self, model) + self.cell = cell + + +class WalkingBehaviorModel: + """Encapsulates all walking decisions for a human agent, including distance checks.""" + + DAILY_PROBABILITIES = { + ActivityType.GROCERY: 0.25, # Every 4 days on average + ActivityType.NON_FOOD_SHOPPING: 0.25, + ActivityType.SOCIAL: 0.15, + ActivityType.LEISURE: 0.20, + } + + BASE_MAX_DISTANCES = { + ActivityType.WORK: 2000, # meters + ActivityType.GROCERY: 1000, + ActivityType.NON_FOOD_SHOPPING: 1500, + ActivityType.SOCIAL: 2000, + ActivityType.LEISURE: 3000, + } + + def __init__(self, model: Model): + self.model = model + self.total_distance_walked = 0 + + def reset_daily_distance(self): + """Reset the total distance walked for a new day""" + self.total_distance_walked = 0 + + def add_distance(self, distance: float): + """Add distance to total daily walking distance""" + self.total_distance_walked += distance + + def get_max_walking_distance(self, human, activity: ActivityType) -> float: + """Calculate person-specific maximum walking distance""" + return self.BASE_MAX_DISTANCES[activity] * human.walking_ability + + def calculate_distance(self, cell1, cell2) -> float: + """Calculate distance between two cells""" + x1, y1 = cell1.coordinate + x2, y2 = cell2.coordinate + return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) + + def decide_walk_to_work(self, human) -> bool: + """Decide if person walks to work""" + if not human.is_working or not human.workplace: + return False + + distance = self.calculate_distance(human.cell, human.workplace.cell) + max_distance = self.get_max_walking_distance(human, ActivityType.WORK) + + if distance <= max_distance: + return self.model.random.random() <= human.walking_attitude + return False + + def find_nearest_location( + self, human, activity_type: ActivityType, search_workplace: bool = True + ) -> Optional[FixedAgent]: + """Find nearest location of given type within walking distance""" + if activity_type == ActivityType.GROCERY: + locations = self.model.agents_by_type[GroceryStore] + elif activity_type == ActivityType.NON_FOOD_SHOPPING: + locations = self.model.agents_by_type[NonFoodShop] + elif activity_type == ActivityType.SOCIAL: + locations = self.model.agents_by_type[SocialPlace] + else: + return None + + max_distance = self.get_max_walking_distance(human, activity_type) + + # Check locations near home + nearest = min( + locations, + key=lambda loc: self.calculate_distance(human.cell, loc.cell), + default=None, + ) + if ( + nearest + and self.calculate_distance(human.cell, nearest.cell) <= max_distance + ): + return nearest + + # Check locations near workplace if applicable + if search_workplace and human.workplace: + nearest = min( + locations, + key=lambda loc: self.calculate_distance(human.workplace.cell, loc.cell), + default=None, + ) + if ( + nearest + and self.calculate_distance(human.workplace.cell, nearest.cell) + <= max_distance + ): + return nearest + + return None + + def decide_leisure_walk(self, human) -> Optional[tuple[float, float]]: + """Decide if person takes a leisure walk""" + if ( + self.model.random.random() + > self.DAILY_PROBABILITIES[ActivityType.LEISURE] * human.walking_attitude + ): + return None + + max_distance = self.get_max_walking_distance(human, ActivityType.LEISURE) + min_distance = max_distance * 0.75 + + # Generate random point within 75-100% of max walking distance + random_cells = [] + all_cells = self.model.grid.all_cells.cells + if len(all_cells) == 0: + return None + for cell in all_cells: + if ( + self.calculate_distance(cell, human.cell) <= max_distance + and self.calculate_distance(cell, human.cell) >= min_distance + ): + random_cells.append(cell) + + if len(random_cells) == 0: + return None + return self.model.random.choice(random_cells) + + def simulate_daily_walks(self, human): + """Simulate a full day of possible walks for the agent.""" + walks = [] + + # Work walk + if self.decide_walk_to_work(human): + distance = self.calculate_distance(human.cell, human.workplace.cell) + self.add_distance(distance) + walks.append((ActivityType.WORK, human.workplace)) + + # Basic needs walks + for activity in [ActivityType.GROCERY, ActivityType.NON_FOOD_SHOPPING]: + if self.model.random.random() <= self.DAILY_PROBABILITIES[activity]: + destination = self.find_nearest_location(human, activity) + if destination and self.model.random.random() <= human.walking_attitude: + distance = self.calculate_distance(human.cell, destination.cell) + self.add_distance(distance) + walks.append((activity, destination)) + + # Social visit + if self.model.random.random() <= self.DAILY_PROBABILITIES[ActivityType.SOCIAL]: + social_place = self.model.random.choice( + self.model.agents_by_type[SocialPlace] + ) + distance = self.calculate_distance(human.cell, social_place.cell) + if distance <= self.get_max_walking_distance(human, ActivityType.SOCIAL): + if self.model.random.random() <= human.walking_attitude: + self.add_distance(distance) + walks.append((ActivityType.SOCIAL, social_place)) + + # Leisure walk + leisure_destination = self.decide_leisure_walk(human) + if leisure_destination: + distance = self.calculate_distance(human.cell, leisure_destination) + self.add_distance(distance) + walks.append((ActivityType.LEISURE, leisure_destination)) + + return walks + + +class Human(CellAgent): + """Represents a person with specific attributes and daily walking behavior.""" + + def __init__(self, model: Model, unique_id: int = 0, cell=None, SES: int = 0): + super().__init__(model) + self.cell = cell + self.unique_id = unique_id + self.SES = SES + + # Human Attributes + self.gender = self.model.generate_gender() + self.age = self.model.generate_age() + self.family_size = self.model.generate_family_size() + self.has_dog = self.model.generate_dog_ownership() + self.walking_ability = self.get_walking_ability() + self.walking_attitude = self.get_walking_attitude() + self.is_working = self._determine_working_status() + self.workplace = self.get_workplace() + self.friends = self.get_friends() + self.family = self.get_family() + + self.previous_walking_density: float = 0 + self.current_walking_density: float + + # Datacollector attributes + self.daily_walking_trips: int = 0 + self.work_trips: int = 0 + self.basic_needs_trips: int = 0 + self.leisure_trips: int = 0 + + # Initialize walking behavior + self.walking_behavior = WalkingBehaviorModel(model) + + def _determine_working_status(self) -> bool: + if self.age >= RETIREMENT_AGE: + return False + return self.model.random.random() < WORKING_PROBABILITY + + def get_friends(self) -> AgentSet: + friend_count = self.model.random.randint(MIN_FRIENDS, MAX_FRIENDS) + friend_set = AgentSet.select( + self.model.agents_by_type[Human], + lambda x: (x.SES > self.SES - 1 and x.SES < self.SES + 1) + and x.unique_id != self.unique_id, + at_most=friend_count, + ) + if len(friend_set) > 0: + for friend in friend_set: + friend.friends.add(self) + return friend_set + + def get_family(self) -> AgentSet: + if self.family_size > 1: + family_set = AgentSet.select( + self.model.agents_by_type[Human], + lambda x: x.gender != self.gender + and abs(x.age - self.age) <= 3, # age difference no more than 3 years + at_most=1, + ) + if len(family_set) > 0: + family_set[0].family = AgentSet([self], random=self.model.random) + return family_set + else: + return None + + def get_workplace(self) -> Optional[Workplace | FixedAgent]: + if not self.is_working: + return None + return self.model.random.choice(self.model.agents_by_type[GroceryStore]) + + def get_walking_ability( + self, + ) -> float: # Method from https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/ + random_component = self.model.random.random() ** 4 + if self.age <= 37: + # For ages up to 37, use the base calculation + return random_component * (min(abs(137 - self.age), 100) / 100) + else: + # For ages over 37, apply linear decrease + base_ability = random_component * (min(abs(137 - self.age), 100) / 100) + age_factor = (self.age - 37) / 50 # Linear decrease factor + return base_ability * (1 - age_factor) + + def get_walking_attitude( + self, + ) -> float: # Method from https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/ + return self.model.random.random() ** 3 + + def get_feedback(self, activity: ActivityType): + a: float = 0.001 # attitude factor + + # 1. Walking attitudes of family members and friends + if self.family: + self.walking_attitude = ((1 - a) * self.walking_attitude) + ( + a * self.family[0].walking_attitude + ) + + if self.friends: + cumulative_friends_attitude: float = 0 # Initialize to 0 + for friend in self.friends: + cumulative_friends_attitude += friend.walking_attitude + # Average the friends' attitudes if there are any + if len(self.friends) > 0: + cumulative_friends_attitude /= len(self.friends) + self.walking_attitude = ((1 - a) * self.walking_attitude) + ( + a * cumulative_friends_attitude + ) + + # 2. Person's walking experience + x, y = self.cell.coordinate + SE_index = ( + ( + self.model.safety_cell_layer.data[x][y] + + self.model.random.uniform(-0.5, 0.5) + ) + * ( + self.model.aesthetic_cell_layer.data[x][y] + + self.model.random.uniform(-0.5, 0.5) + ) + ) / np.mean( + self.model.safety_cell_layer.data * self.model.aesthetic_cell_layer.data + ) + + # 3. Density of other walkers + neighbour_cells = self.cell.get_neighborhood(radius=2) + num_neighbours = [i for i in neighbour_cells if i.agents] + self.current_walking_density = len(num_neighbours) / len(neighbour_cells) + density_feedback = 0 + if self.previous_walking_density == 0: + # If previous density was zero, treat any current density as a positive change + if self.current_walking_density > 0: + density_feedback = 1 + else: + density_feedback = 0 + else: + density_ratio = self.current_walking_density / self.previous_walking_density + density_feedback = density_ratio - 1 # Centers the feedback around 0 + + self.previous_walking_density = self.current_walking_density + + # 4. Total amount walked by the person during that day + walking_feedback = 0 + if self.walking_behavior.total_distance_walked > 0: + max_personal_distance = ( + self.walking_behavior.get_max_walking_distance(self, activity) + * self.walking_ability + ) + walking_feedback = min( + 1, max_personal_distance / self.walking_behavior.total_distance_walked + ) + + # Update walking attitude + self.walking_attitude = ( + self.walking_attitude + * (1 - a + (a * SE_index)) + * (1 - a + (a * density_feedback)) + * (1 - a + (a * walking_feedback)) + ) + + def step(self): + """Execute one simulation step: decide on daily walks, update feedback.""" + daily_walks = self.walking_behavior.simulate_daily_walks(self) + + # Update datacollector attributes + self.daily_walking_trips = len(daily_walks) + self.work_trips = sum( + [1 for activity, _ in daily_walks if activity == ActivityType.WORK] + ) + self.basic_needs_trips = sum( + [ + 1 + for activity, _ in daily_walks + if activity in [ActivityType.GROCERY, ActivityType.NON_FOOD_SHOPPING] + ] + ) + self.leisure_trips = sum( + [1 for activity, _ in daily_walks if activity == ActivityType.LEISURE] + ) + + if len(daily_walks) > 0: + for activity, destination in daily_walks: + self.get_feedback(activity) + # Move agent to new cell if applicable + if isinstance(destination, FixedAgent): + self.cell = destination.cell + elif isinstance(destination, Cell): + self.cell = destination diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py new file mode 100644 index 00000000..2fa694ac --- /dev/null +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -0,0 +1,633 @@ +import math +from mesa import Model +from city_walking_behaviour.agents import Human, GroceryStore, SocialPlace, NonFoodShop, Other +from mesa.experimental.cell_space import OrthogonalVonNeumannGrid +from mesa.experimental.cell_space.property_layer import PropertyLayer +from mesa.experimental.devs import ABMSimulator +from mesa.datacollection import DataCollector +from .agents import ( + FEMALE_PROBABILITY, + DOG_OWNER_PROBABILITY, + SINGLE_HOUSEHOLD_PROBABILITY, + MIN_AGE, + MAX_AGE, +) + +SCENARIOS = { + "random_random": "Random Land Use, Random Safety", + "random_safe": "Random Land Use, Low Safety in Core", + "central_random": "Centralized Land Use, Random Safety", + "central_safe": "Centralized Land Use, Low Safety in Core", +} + + +class WalkingModel(Model): + def __init__( + self, + height: int = 40, + width: int = 40, + no_of_couples: int = 2400, + no_of_singles: int = 600, + no_of_grocery_stores: int = 10, + no_of_social_places: int = 75, + no_of_non_food_shops: int = 40, + no_of_others: int = 475, + scenario: str = "random_random", + seed=None, + simulator=ABMSimulator(), + ): + super().__init__(seed=seed) + self.simulator = simulator + self.simulator.setup(self) + + # Initialize basic properties + self.initialize_properties( + height, + width, + no_of_couples, + no_of_singles, + no_of_grocery_stores, + no_of_social_places, + no_of_non_food_shops, + no_of_others, + ) + + # Set up grid and layers + self.setup_grid_and_layers() + + # Apply selected scenario + self.apply_scenario(scenario) + + # Model reporters: Fixed SES references for b_SES_4, c_SES_4, d_SES_4 + model_reporters = { + "avg_walk_ses1": lambda x: ( # average daily walking trips for SES=1 + sum( + agent.daily_walking_trips + for agent in x.agents_by_type[Human] + if agent.SES == 1 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_walk_ses2": lambda x: ( # average daily walking trips for SES=2 + sum( + agent.daily_walking_trips + for agent in x.agents_by_type[Human] + if agent.SES == 2 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_walk_ses3": lambda x: ( # average daily walking trips for SES=3 + sum( + agent.daily_walking_trips + for agent in x.agents_by_type[Human] + if agent.SES == 3 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_walk_ses4": lambda x: ( # average daily walking trips for SES=4 + sum( + agent.daily_walking_trips + for agent in x.agents_by_type[Human] + if agent.SES == 4 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_walk_ses5": lambda x: ( # average daily walking trips for SES=5 + sum( + agent.daily_walking_trips + for agent in x.agents_by_type[Human] + if agent.SES == 5 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_work_ses1": lambda x: ( # average work trips for SES=1 + sum( + agent.work_trips + for agent in x.agents_by_type[Human] + if agent.SES == 1 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_work_ses2": lambda x: ( # average work trips for SES=2 + sum( + agent.work_trips + for agent in x.agents_by_type[Human] + if agent.SES == 2 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_work_ses3": lambda x: ( # average work trips for SES=3 + sum( + agent.work_trips + for agent in x.agents_by_type[Human] + if agent.SES == 3 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_work_ses4": lambda x: ( # average work trips for SES=4 + sum( + agent.work_trips + for agent in x.agents_by_type[Human] + if agent.SES == 4 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_work_ses5": lambda x: ( # average work trips for SES=5 + sum( + agent.work_trips + for agent in x.agents_by_type[Human] + if agent.SES == 5 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_basic_ses1": lambda x: ( # average basic-needs trips for SES=1 + sum( + agent.basic_needs_trips + for agent in x.agents_by_type[Human] + if agent.SES == 1 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_basic_ses2": lambda x: ( # average basic-needs trips for SES=2 + sum( + agent.basic_needs_trips + for agent in x.agents_by_type[Human] + if agent.SES == 2 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_basic_ses3": lambda x: ( # average basic-needs trips for SES=3 + sum( + agent.basic_needs_trips + for agent in x.agents_by_type[Human] + if agent.SES == 3 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_basic_ses4": lambda x: ( # average basic-needs trips for SES=4 + sum( + agent.basic_needs_trips + for agent in x.agents_by_type[Human] + if agent.SES == 4 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_basic_ses5": lambda x: ( # average basic-needs trips for SES=5 + sum( + agent.basic_needs_trips + for agent in x.agents_by_type[Human] + if agent.SES == 5 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_leisure_ses1": lambda x: ( # average leisure trips for SES=1 + sum( + agent.leisure_trips + for agent in x.agents_by_type[Human] + if agent.SES == 1 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_leisure_ses2": lambda x: ( # average leisure trips for SES=2 + sum( + agent.leisure_trips + for agent in x.agents_by_type[Human] + if agent.SES == 2 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_leisure_ses3": lambda x: ( # average leisure trips for SES=3 + sum( + agent.leisure_trips + for agent in x.agents_by_type[Human] + if agent.SES == 3 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_leisure_ses4": lambda x: ( # average leisure trips for SES=4 + sum( + agent.leisure_trips + for agent in x.agents_by_type[Human] + if agent.SES == 4 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + "avg_leisure_ses5": lambda x: ( # average leisure trips for SES=5 + sum( + agent.leisure_trips + for agent in x.agents_by_type[Human] + if agent.SES == 5 + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ), + } + + self.datacollector = DataCollector(model_reporters) + # Add initial humans + self.add_initial_humans() + + self.datacollector.collect(self) + self.running = True + + def initialize_properties( + self, + height, + width, + no_of_couples, + no_of_singles, + no_of_grocery_stores, + no_of_social_places, + no_of_non_food_shops, + no_of_others, + ): + """Initialize basic model properties.""" + self.height = height + self.width = width + self.no_of_couples = no_of_couples + self.no_of_singles = no_of_singles + self.no_of_grocery_stores = no_of_grocery_stores + self.no_of_social_places = no_of_social_places + self.no_of_non_food_shops = no_of_non_food_shops + self.no_of_others = no_of_others + self.no_of_humans = 2 * self.no_of_couples + self.no_of_singles + self.unique_id = 1 + + def setup_grid_and_layers(self): + """Set up the visual grid and associated layers.""" + self.grid = OrthogonalVonNeumannGrid( + [self.height, self.width], + torus=True, + capacity=math.inf, + random=self.random, + ) + self.safety_cell_layer = PropertyLayer( + "safety", (self.width, self.height), dtype=float + ) + self.aesthetic_cell_layer = PropertyLayer( + "aesthetic", (self.width, self.height), dtype=float + ) + self.setup_aesthetic_layer() + + def setup_aesthetic_layer(self): + """Setup aesthetic distribution with central tendency and organic variations""" + center_x, center_y = self.height // 2, self.width // 2 + max_distance = math.sqrt((self.height // 2) ** 2 + (self.width // 2) ** 2) + + # Create multiple aesthetic hotspots + hotspots = [ + (center_x, center_y, 1.0), # Main center, full weight + ( + center_x + self.height // 4, + center_y + self.width // 4, + 0.7, + ), # Secondary spots + (center_x - self.height // 4, center_y - self.width // 4, 0.7), + (center_x + self.height // 3, center_y - self.width // 3, 0.5), + (center_x - self.height // 3, center_y + self.width // 3, 0.5), + ] + + # Create base noise grid for organic variation + noise_grid = [ + [self.random.random() * 0.3 for _ in range(self.width)] + for _ in range(self.height) + ] + + # Apply smoothing to noise + smoothing_radius = 2 + smoothed_noise = [[0 for _ in range(self.width)] for _ in range(self.height)] + for i in range(self.height): + for j in range(self.width): + total = 0 + count = 0 + for di in range(-smoothing_radius, smoothing_radius + 1): + for dj in range(-smoothing_radius, smoothing_radius + 1): + ni, nj = (i + di) % self.height, (j + dj) % self.width + total += noise_grid[ni][nj] + count += 1 + smoothed_noise[i][j] = total / count + + def calculate_aesthetic_value(i, j): + # Calculate influence from all hotspots + hotspot_influence = 0 + total_weight = 0 + + for hx, hy, weight in hotspots: + distance = math.sqrt((i - hx) ** 2 + (j - hy) ** 2) + normalized_distance = distance / max_distance + + # Exponential decay with distance + influence = math.exp(-2 * normalized_distance) * weight + hotspot_influence += influence + total_weight += weight + + # Normalize hotspot influence + hotspot_value = hotspot_influence / total_weight + + # Combine with smoothed noise + base_value = hotspot_value * 0.7 + smoothed_noise[i][j] + + # Add some local character + local_variation = self.random.random() * 0.2 + if base_value > 0.7: # High aesthetic areas get more consistent + local_variation *= 0.5 + + # Add some "character" to certain areas + if 0.3 < base_value < 0.7: # Mid-range areas get more variation + local_variation *= 1.5 + + final_value = base_value + local_variation + + # Ensure value stays within [0, 1] + return max(0.1, min(1.0, final_value)) + + for i in range(self.height): + for j in range(self.width): + self.aesthetic_cell_layer.data[i][j] = calculate_aesthetic_value(i, j) + + # Apply final smoothing pass for more natural transitions + final_smoothing = 1 + for i in range(self.height): + for j in range(self.width): + total = 0 + count = 0 + for di in range(-final_smoothing, final_smoothing + 1): + for dj in range(-final_smoothing, final_smoothing + 1): + ni, nj = (i + di) % self.height, (j + dj) % self.width + total += self.aesthetic_cell_layer.data[ni][nj] + count += 1 + self.aesthetic_cell_layer.data[i][j] = total / count + + def apply_scenario(self, scenario: str): + """Apply chosen scenario for building placement & safety setup.""" + scenario_handlers = { + "random_random": self._apply_random_random, + "random_safe": self._apply_random_safe, + "central_random": self._apply_central_random, + "central_safe": self._apply_central_safe, + } + if scenario not in scenario_handlers: + raise ValueError(f"Invalid scenario: {scenario}") + scenario_handlers[scenario]() + + def _place_buildings_random(self): + """Place buildings randomly on the grid.""" + building_types = [ + (GroceryStore, self.no_of_grocery_stores), + (SocialPlace, self.no_of_social_places), + (NonFoodShop, self.no_of_non_food_shops), + (Other, self.no_of_others), + ] + + for building_type, count in building_types: + for _ in range(count): + cell = self.grid.select_random_empty_cell() + if cell is not None: + building_type(self, cell) + + def _place_buildings_central(self): + """Place buildings with stronger central tendency using exponential decay.""" + center_x, center_y = self.width // 2, self.height // 2 + max_distance = math.sqrt((self.width // 2) ** 2 + (self.height // 2) ** 2) + + # Different weights for different building types (personal preference) + building_types = [ + (GroceryStore, self.no_of_grocery_stores, 0.7), # Less centralized + (SocialPlace, self.no_of_social_places, 0.85), # More centralized + (NonFoodShop, self.no_of_non_food_shops, 0.9), # Highly centralized + (Other, self.no_of_others, 0.6), # Least centralized + ] + + def get_placement_probability(x, y, centralization_weight): + # Calculate distance from center + distance = math.sqrt((x - center_x) ** 2 + (y - center_y) ** 2) + normalized_distance = distance / max_distance + + # Exponential decay function for stronger central tendency + prob = math.exp(-centralization_weight * normalized_distance * 3) + + # Add some randomness to avoid perfect circles + prob *= 0.8 + 0.4 * self.random.random() + + return prob + + for building_type, count, centralization_weight in building_types: + buildings_placed = 0 + max_attempts = count * 20 + attempts = 0 + + while buildings_placed < count and attempts < max_attempts: + cell = self.grid.select_random_empty_cell() + if cell is None: + break + + x, y = cell.coordinate + if self.random.random() < get_placement_probability( + x, y, centralization_weight + ): + building_type(self, cell) + buildings_placed += 1 + attempts += 1 + + def _setup_random_safety(self): + """Set up random safety values with some spatial correlation.""" + # Initialize with pure random values + base_values = [ + [self.random.random() for _ in range(self.width)] + for _ in range(self.height) + ] + + # Apply smoothing to create more realistic patterns + smoothing_radius = 2 + for i in range(self.height): + for j in range(self.width): + # Calculate average of neighboring cells + total = 0 + count = 0 + for di in range(-smoothing_radius, smoothing_radius + 1): + for dj in range(-smoothing_radius, smoothing_radius + 1): + ni, nj = (i + di) % self.height, (j + dj) % self.width + total += base_values[ni][nj] + count += 1 + + # Set smoothed value with some random variation + smoothed = total / count + variation = (self.random.random() - 0.5) * 0.2 # +/-0.1 variation + self.safety_cell_layer.data[i][j] = max( + 0.1, min(1.0, smoothed + variation) + ) + + def _setup_central_safety(self): + """Set up safety values with realistic urban-like distribution.""" + center_x, center_y = self.height // 2, self.width // 2 + max_distance = math.sqrt((self.height // 2) ** 2 + (self.width // 2) ** 2) + + # Create multiple centers of low safety to simulate urban clusters + centers = [ + (center_x, center_y), # Main center + ( + center_x + self.height // 4, + center_y + self.width // 4, + ), # Secondary centers + (center_x - self.height // 4, center_y - self.width // 4), + (center_x + self.height // 4, center_y - self.width // 4), + (center_x - self.height // 4, center_y + self.width // 4), + ] + + def calculate_safety(i, j): + distances = [math.sqrt((i - cx) ** 2 + (j - cy) ** 2) for cx, cy in centers] + min_distance = min(distances) / max_distance + + # Base safety increases with distance from centers + base_safety = min_distance * 0.8 # Maximum safety of 0.8 + + # Add some local variation + local_variation = self.random.random() * 0.2 + + # Add some urban-like patterns + if min_distance < 0.3: # Near centers + # More variation in central areas + safety = base_safety + local_variation + else: # Suburban and outer areas + # More consistent safety levels + safety = base_safety + (local_variation * 0.5) + + # safety stays within [0, 1] + return max(0.1, min(1.0, safety)) + + for i in range(self.height): + for j in range(self.width): + self.safety_cell_layer.data[i][j] = calculate_safety(i, j) + + def _apply_random_random(self): + self._place_buildings_random() + self._setup_random_safety() + + def _apply_random_safe(self): + self._place_buildings_random() + self._setup_central_safety() + + def _apply_central_random(self): + self._place_buildings_central() + self._setup_random_safety() + + def _apply_central_safe(self): + self._place_buildings_central() + self._setup_central_safety() + + def add_initial_humans(self): + """Add initial humans with distance-based cell organization.""" + center_x, center_y = self.height // 2, self.width // 2 + max_distance = math.sqrt((self.height // 2) ** 2 + (self.width // 2) ** 2) + + # Initialize dictionary for each SES level + cells_with_proximity = {1: [], 2: [], 3: [], 4: [], 5: []} + + # Categorize all empty cells based on their distance from center + for cell in self.grid.all_cells: + if not cell.empty: + continue + + x, y = cell.coordinate + distance = math.sqrt((x - center_x) ** 2 + (y - center_y) ** 2) + normalized_distance = distance / max_distance + + # Assign to SES levels based on normalized distance + if normalized_distance < 0.2: + cells_with_proximity[1].append(cell) + elif normalized_distance < 0.4: + cells_with_proximity[2].append(cell) + elif normalized_distance < 0.6: + cells_with_proximity[3].append(cell) + elif normalized_distance < 0.8: + cells_with_proximity[4].append(cell) + else: + cells_with_proximity[5].append(cell) + + # Place couples + for _ in range(self.no_of_couples): + ses = self.generate_ses() + if cells_with_proximity[ses]: + if len(cells_with_proximity[ses]) >= 2: + cell = self.random.choice(cells_with_proximity[ses]) + cells_with_proximity[ses].remove(cell) + # Create the couple + for _ in range(2): + Human(self, self.unique_id, cell, SES=ses) + self.unique_id += 1 + + # Place singles + for _ in range(self.no_of_singles): + ses = self.generate_ses() + if cells_with_proximity[ses]: + cell = self.random.choice(cells_with_proximity[ses]) + cells_with_proximity[ses].remove(cell) + Human(self, self.unique_id, cell, SES=ses) + self.unique_id += 1 + + def step(self): + """Advance the model by one step.""" + self.agents_by_type[Human].shuffle_do("step") + + # Reset daily walking trips + self.agents_by_type[Human].daily_walking_trips = 0 + self.daily_walking_trips = 0 + self.work_trips = 0 + self.basic_needs_trips = 0 + self.leisure_trips = 0 + + self.datacollector.collect(self) + + def generate_gender(self) -> str: + return "Female" if self.random.random() < FEMALE_PROBABILITY else "Male" + + def generate_age(self) -> int: + return self.random.randint(MIN_AGE, MAX_AGE) + + def generate_ses(self) -> int: + return self.random.randint(1, 5) + + def generate_family_size(self) -> int: + return 1 if self.random.random() < SINGLE_HOUSEHOLD_PROBABILITY else 2 + + def generate_dog_ownership(self) -> bool: + return self.random.random() < DOG_OWNER_PROBABILITY From c850d5f9df9bee4751aad47e8c7012d8f260910a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 Dec 2024 17:12:42 +0000 Subject: [PATCH 02/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/city_walking_behaviour/app.py | 66 ++++++++++++++++--- .../city_walking_behaviour/model.py | 10 ++- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/examples/city_walking_behaviour/app.py b/examples/city_walking_behaviour/app.py index 104c2ffd..9673b40c 100644 --- a/examples/city_walking_behaviour/app.py +++ b/examples/city_walking_behaviour/app.py @@ -1,4 +1,10 @@ -from city_walking_behaviour.agents import Human, GroceryStore, SocialPlace, NonFoodShop, Other +from city_walking_behaviour.agents import ( + Human, + GroceryStore, + SocialPlace, + NonFoodShop, + Other, +) from city_walking_behaviour.model import WalkingModel from mesa.experimental.devs import ABMSimulator from mesa.visualization import ( @@ -208,20 +214,62 @@ def post_process_lines_leisure(ax): def post_process_buildings_legend(ax): import matplotlib.lines as mlines + # Create legend entries for each building/agent - grocery_store = mlines.Line2D([], [], color="tab:green", marker="s", linestyle="None", markersize=10, label="Grocery Store") - social_place = mlines.Line2D([], [], color="tab:purple", marker="s", linestyle="None", markersize=10, label="Social Place") - non_food_shop = mlines.Line2D([], [], color="tab:olive", marker="s", linestyle="None", markersize=10, label="Non-Food Shop") - other_building = mlines.Line2D([], [], color="tab:brown", marker="s", linestyle="None", markersize=10, label="Other") - human = mlines.Line2D([], [], color="tab:red", marker="v", linestyle="None", markersize=10, label="Human") + grocery_store = mlines.Line2D( + [], + [], + color="tab:green", + marker="s", + linestyle="None", + markersize=10, + label="Grocery Store", + ) + social_place = mlines.Line2D( + [], + [], + color="tab:purple", + marker="s", + linestyle="None", + markersize=10, + label="Social Place", + ) + non_food_shop = mlines.Line2D( + [], + [], + color="tab:olive", + marker="s", + linestyle="None", + markersize=10, + label="Non-Food Shop", + ) + other_building = mlines.Line2D( + [], + [], + color="tab:brown", + marker="s", + linestyle="None", + markersize=10, + label="Other", + ) + human = mlines.Line2D( + [], + [], + color="tab:red", + marker="v", + linestyle="None", + markersize=10, + label="Human", + ) ax.legend( handles=[grocery_store, social_place, non_food_shop, other_building, human], loc="center", - bbox_to_anchor=(0.5, 0.5) + bbox_to_anchor=(0.5, 0.5), ) ax.axis("off") + space_component = make_space_component( agent_portrayal, draw_grid=True, post_process=post_process_space ) @@ -270,7 +318,9 @@ def post_process_buildings_legend(ax): post_process=post_process_lines_leisure, ) -plot_component_legend = make_plot_component({}, post_process=post_process_buildings_legend) +plot_component_legend = make_plot_component( + {}, post_process=post_process_buildings_legend +) # Initialize and run the model simulator = ABMSimulator() diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py index 2fa694ac..1aa0642c 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/model.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -1,6 +1,12 @@ import math from mesa import Model -from city_walking_behaviour.agents import Human, GroceryStore, SocialPlace, NonFoodShop, Other +from city_walking_behaviour.agents import ( + Human, + GroceryStore, + SocialPlace, + NonFoodShop, + Other, +) from mesa.experimental.cell_space import OrthogonalVonNeumannGrid from mesa.experimental.cell_space.property_layer import PropertyLayer from mesa.experimental.devs import ABMSimulator @@ -521,7 +527,7 @@ def calculate_safety(i, j): base_safety = min_distance * 0.8 # Maximum safety of 0.8 # Add some local variation - local_variation = self.random.random() * 0.2 + local_variation = self.random.random() * 0.2 # Add some urban-like patterns if min_distance < 0.3: # Near centers From 058453d3998dcac8c4afaf48e6b4d89169f2eb16 Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Fri, 27 Dec 2024 23:08:11 +0530 Subject: [PATCH 03/13] added pre-commit suggestions --- examples/city_walking_behaviour/app.py | 8 ++--- .../city_walking_behaviour/agents.py | 25 +++++++------ .../city_walking_behaviour/model.py | 36 +++++++++---------- 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/examples/city_walking_behaviour/app.py b/examples/city_walking_behaviour/app.py index 9673b40c..2037206a 100644 --- a/examples/city_walking_behaviour/app.py +++ b/examples/city_walking_behaviour/app.py @@ -1,16 +1,16 @@ from city_walking_behaviour.agents import ( - Human, GroceryStore, - SocialPlace, + Human, NonFoodShop, Other, + SocialPlace, ) from city_walking_behaviour.model import WalkingModel from mesa.experimental.devs import ABMSimulator from mesa.visualization import ( SolaraViz, - make_space_component, make_plot_component, + make_space_component, ) # Define the scenarios @@ -347,4 +347,4 @@ def post_process_buildings_legend(ax): name="Walking Model", simulator=simulator, ) -page +page # noqa diff --git a/examples/city_walking_behaviour/city_walking_behaviour/agents.py b/examples/city_walking_behaviour/city_walking_behaviour/agents.py index 344a33b6..d9397c8a 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/agents.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/agents.py @@ -1,11 +1,11 @@ -from typing import Optional -from mesa import Model import math -from mesa.agent import AgentSet -from mesa.experimental.cell_space import CellAgent, FixedAgent -from mesa.experimental.cell_space import Cell from enum import Enum +from typing import Optional + import numpy as np +from mesa import Model +from mesa.agent import AgentSet +from mesa.experimental.cell_space import Cell, CellAgent, FixedAgent # Constants for probability values FEMALE_PROBABILITY = 0.5 @@ -209,10 +209,12 @@ def simulate_daily_walks(self, human): self.model.agents_by_type[SocialPlace] ) distance = self.calculate_distance(human.cell, social_place.cell) - if distance <= self.get_max_walking_distance(human, ActivityType.SOCIAL): - if self.model.random.random() <= human.walking_attitude: - self.add_distance(distance) - walks.append((ActivityType.SOCIAL, social_place)) + if ( + distance <= self.get_max_walking_distance(human, ActivityType.SOCIAL) + and self.model.random.random() <= human.walking_attitude + ): + self.add_distance(distance) + walks.append((ActivityType.SOCIAL, social_place)) # Leisure walk leisure_destination = self.decide_leisure_walk(human) @@ -354,10 +356,7 @@ def get_feedback(self, activity: ActivityType): density_feedback = 0 if self.previous_walking_density == 0: # If previous density was zero, treat any current density as a positive change - if self.current_walking_density > 0: - density_feedback = 1 - else: - density_feedback = 0 + density_feedback = 1 if self.current_walking_density > 0 else 0 else: density_ratio = self.current_walking_density / self.previous_walking_density density_feedback = density_ratio - 1 # Centers the feedback around 0 diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py index 1aa0642c..0cdd3dd6 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/model.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -1,23 +1,22 @@ import math -from mesa import Model + from city_walking_behaviour.agents import ( - Human, + DOG_OWNER_PROBABILITY, + FEMALE_PROBABILITY, + MAX_AGE, + MIN_AGE, + SINGLE_HOUSEHOLD_PROBABILITY, GroceryStore, - SocialPlace, + Human, NonFoodShop, Other, + SocialPlace, ) +from mesa import Model +from mesa.datacollection import DataCollector from mesa.experimental.cell_space import OrthogonalVonNeumannGrid from mesa.experimental.cell_space.property_layer import PropertyLayer from mesa.experimental.devs import ABMSimulator -from mesa.datacollection import DataCollector -from .agents import ( - FEMALE_PROBABILITY, - DOG_OWNER_PROBABILITY, - SINGLE_HOUSEHOLD_PROBABILITY, - MIN_AGE, - MAX_AGE, -) SCENARIOS = { "random_random": "Random Land Use, Random Safety", @@ -592,14 +591,13 @@ def add_initial_humans(self): # Place couples for _ in range(self.no_of_couples): ses = self.generate_ses() - if cells_with_proximity[ses]: - if len(cells_with_proximity[ses]) >= 2: - cell = self.random.choice(cells_with_proximity[ses]) - cells_with_proximity[ses].remove(cell) - # Create the couple - for _ in range(2): - Human(self, self.unique_id, cell, SES=ses) - self.unique_id += 1 + if cells_with_proximity[ses] and len(cells_with_proximity[ses]) >= 2: + cell = self.random.choice(cells_with_proximity[ses]) + cells_with_proximity[ses].remove(cell) + # Create the couple + for _ in range(2): + Human(self, self.unique_id, cell, SES=ses) + self.unique_id += 1 # Place singles for _ in range(self.no_of_singles): From 91abe57a588373c59511e0780397a1360304649f Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Sat, 28 Dec 2024 11:23:06 +0530 Subject: [PATCH 04/13] added model description --- examples/city_walking_behaviour/Readme.md | 121 ++++++++++++++++++++-- 1 file changed, 110 insertions(+), 11 deletions(-) diff --git a/examples/city_walking_behaviour/Readme.md b/examples/city_walking_behaviour/Readme.md index efb2e591..dd2fdb53 100644 --- a/examples/city_walking_behaviour/Readme.md +++ b/examples/city_walking_behaviour/Readme.md @@ -1,24 +1,123 @@ # Walking Behavior Agent-Based Model -This repository contains an agent-based model (ABM) that simulates walking behavior in a hypothetical city, examining how socioeconomic status (SES), built environment, and social factors influence walking patterns. +## Introduction -This ABM investigates spatial determinants of social inequalities in walking behavior by modeling dynamic interactions between individual attributes and environmental factors. The model incorporates feedback mechanisms and individual-environment interactions to simulate realistic walking patterns across different socioeconomic groups. +This agent-based model (ABM) simulates walking behavior patterns in a hypothetical city. It examines how socioeconomic status (SES), built environment, and social factors influence walking patterns by modeling dynamic interactions between individual attributes and environmental factors. The model incorporates feedback mechanisms and individual-environment interactions to simulate realistic walking patterns across different socioeconomic groups. -## How to Run +## Model Architecture -To run a basic simulation: +### Core Components -```python +#### Initialization Parameters -solara run app.py +- Grid dimensions (width and height) +- Workplace distribution (Grocery Stores, Social Places, etc.) +- Population demographics (couples and singles) + +#### Simulation Scenarios + +1. **RR (Random-Random)** + + - Random land use distribution + - Random safety values + +2. **RS (Random-Safety)** + + - Random land use distribution + - Lower safety values in core areas + +3. **CR (Centralized-Random)** + + - Centralized land use + - Random safety values + +4. **CS (Centralized-Safety)** + - Centralized land use + - Lower safety values in core areas + +### Environmental Systems + +#### Environmental Layers + +1. **Safety Layer** (`safety_cell_layer`) + + - Dynamic values based on scenario + - Influences walking behavior and route selection + +2. **Aesthetic Layer** (`aesthetic_cell_layer`) + - Center-based value distribution + - Impacts route preferences + +#### Agent Placement System + +- Scenario-based workplace distribution +- Household-based agent spawning +- SES-correlated positioning (lower SES centrally located) + +### Data Collection System + +Tracks metrics across five SES levels (1-5): +- Average daily walking trips +- Work-related trips +- Basic needs trips (grocery and retail) +- Leisure walks + +## Agent Implementation + +### Human Agent Characteristics + +#### Demographics + +- Gender (50/50 distribution) +- Age (18-87 years, random distribution) +- Family Size (1-2 members) +- Pet Ownership (20% dog ownership rate) + +#### Personal Attributes + +- Walking Ability +- Walking Attitude +- Employment Status +- Social Networks + +#### Behavioral Feedback Mechanisms + +Walking attitudes influenced by: + +- Social network dynamics +- Environmental conditions +- Pedestrian density +- Walking history + +### Walking Behavior Model + +- Activity probability modeling +- Distance threshold management +- Schedule optimization +- Destination planning + +### Workplace Hierarchy + +1. Base Workplace (Abstract) +2. GroceryStore +3. NonFoodShop +4. SocialPlace +5. Other + +## Usage + +### Running the Simulation + +```python +solara run app.py ``` -# Files +## Project Structure -- [city_walking_behaviour/model.py](city_walking_behaviour/model.py): Core model file. -- [city_walking_behaviour/agents.py](city_walking_behaviour/agents.py): The agent class. +- `city_walking_behaviour/model.py`: Core simulation engine +- `city_walking_behaviour/agents.py`: Agent class definitions -## Further Reading +## References -1. A Spatial Agent-Based Model for the Simulation of Adults’ Daily Walking Within a City [article](https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/) +1. [A Spatial Agent-Based Model for the Simulation of Adults' Daily Walking Within a City](https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/) From f405721af089ab7567a04c5cdd12826fdbce00ea Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Sat, 28 Dec 2024 12:10:07 +0530 Subject: [PATCH 05/13] updated readme --- examples/city_walking_behaviour/Readme.md | 150 +++++++++++----------- 1 file changed, 74 insertions(+), 76 deletions(-) diff --git a/examples/city_walking_behaviour/Readme.md b/examples/city_walking_behaviour/Readme.md index dd2fdb53..1c78827d 100644 --- a/examples/city_walking_behaviour/Readme.md +++ b/examples/city_walking_behaviour/Readme.md @@ -1,123 +1,121 @@ # Walking Behavior Agent-Based Model -## Introduction +This repository contains an agent-based model (ABM) that simulates walking behavior in a hypothetical city, examining how socioeconomic status (SES), built environment, and social factors influence walking patterns. -This agent-based model (ABM) simulates walking behavior patterns in a hypothetical city. It examines how socioeconomic status (SES), built environment, and social factors influence walking patterns by modeling dynamic interactions between individual attributes and environmental factors. The model incorporates feedback mechanisms and individual-environment interactions to simulate realistic walking patterns across different socioeconomic groups. +# Walking Behavior Simulation Model Documentation -## Model Architecture +## Overview -### Core Components +## Model Architecture (`model.py`) -#### Initialization Parameters +### Initialization Parameters - Grid dimensions (width and height) -- Workplace distribution (Grocery Stores, Social Places, etc.) -- Population demographics (couples and singles) +- Number of workplaces (categorized into Grocery Stores, Social Places, etc.) +- Population composition (number of couples and singles) -#### Simulation Scenarios +### Simulation Scenarios -1. **RR (Random-Random)** +The model implements four distinct scenarios: - - Random land use distribution - - Random safety values +1. **RR (Random-Random)**: Random land use distribution with random safety values +2. **RS (Random-Safety)**: Random land use distribution with lower safety values in core areas +3. **CR (Centralized-Random)**: Centralized land use with random safety values +4. **CS (Centralized-Safety)**: Centralized land use with lower safety values in core areas -2. **RS (Random-Safety)** +### Environmental Layers - - Random land use distribution - - Lower safety values in core areas - -3. **CR (Centralized-Random)** - - - Centralized land use - - Random safety values +1. **Safety Layer** (`safety_cell_layer`) -4. **CS (Centralized-Safety)** - - Centralized land use - - Lower safety values in core areas + - Values vary based on selected scenario + - Impacts walking behavior and route choices -### Environmental Systems +2. **Aesthetic Layer** (`aesthetic_cell_layer`) + - Values decrease with distance from center + - Reflects personal preferences in route selection -#### Environmental Layers +### Agent Placement -1. **Safety Layer** (`safety_cell_layer`) +- Workplaces are distributed according to scenario parameters +- Households serve as spawn locations for human agents +- Agent placement correlates with Socioeconomic Status (SES) - lower SES values correspond to more central locations - - Dynamic values based on scenario - - Influences walking behavior and route selection +### Data Collection -2. **Aesthetic Layer** (`aesthetic_cell_layer`) - - Center-based value distribution - - Impacts route preferences +The model tracks the following metrics across five SES levels (1-5): -#### Agent Placement System +1. Average daily walking trips +2. Work-related trips +3. Basic needs trips (grocery and non-food shopping) +4. Leisure trips (non-purposeful neighborhood walks) -- Scenario-based workplace distribution -- Household-based agent spawning -- SES-correlated positioning (lower SES centrally located) +## Agent Implementation (`agents.py`) -### Data Collection System +### Human Class -Tracks metrics across five SES levels (1-5): +Extends the CellAgent class with the following attributes: -- Average daily walking trips -- Work-related trips -- Basic needs trips (grocery and retail) -- Leisure walks +#### Demographic Characteristics -## Agent Implementation +- Gender: Equal probability of male/female +- Age: Random distribution (18-87 years) +- Family Size: 1 or 2 (based on `SINGLE_HOUSEHOLD_PROBABILITY`) +- Pet Ownership: 20% probability of dog ownership (increases leisure walking frequency) -### Human Agent Characteristics +#### Personal Attributes -#### Demographics +- Walking Ability: Determined by `get_walking_ability` function +- Walking Attitude: Calculated via `get_walking_attitude` function +- Employment Status: + - Automatic retirement above `RETIREMENT_AGE` + - 95% employment probability for working-age population +- Social Network: Maintains lists of friends and family members -- Gender (50/50 distribution) -- Age (18-87 years, random distribution) -- Family Size (1-2 members) -- Pet Ownership (20% dog ownership rate) +#### Behavioral Feedback System -#### Personal Attributes +Walking attitude is influenced by: -- Walking Ability -- Walking Attitude -- Employment Status -- Social Networks +- Social network (family and friends' attitudes) +- Environmental factors (safety and aesthetics) +- Local pedestrian density +- Cumulative walking distance -#### Behavioral Feedback Mechanisms +### WalkingBehaviourModel Class -Walking attitudes influenced by: +Manages walking behavior simulation with: -- Social network dynamics -- Environmental conditions -- Pedestrian density -- Walking history +- Activity probability distributions +- Maximum distance thresholds +- Daily walk scheduling based on destination distances +- Activity and destination planning algorithms -### Walking Behavior Model +### Workplace Classes -- Activity probability modeling -- Distance threshold management -- Schedule optimization -- Destination planning +A hierarchy of workplace types: -### Workplace Hierarchy +1. **Base Workplace**: Abstract class for workplace definition +2. **GroceryStore**: Essential food retail +3. **NonFoodShop**: General retail +4. **SocialPlace**: Community gathering locations +5. **Other**: Miscellaneous workplace types -1. Base Workplace (Abstract) -2. GroceryStore -3. NonFoodShop -4. SocialPlace -5. Other +All workplace classes inherit from both `Workplace` and `FixedAgent` base classes. -## Usage +## How to Run -### Running the Simulation +To run a basic simulation: ```python + solara run app.py + ``` -## Project Structure +# Files -- `city_walking_behaviour/model.py`: Core simulation engine -- `city_walking_behaviour/agents.py`: Agent class definitions +- [city_walking_behaviour/model.py](city_walking_behaviour/model.py): Core model file. +- [city_walking_behaviour/agents.py](city_walking_behaviour/agents.py): The agent class. -## References +## Further Reading -1. [A Spatial Agent-Based Model for the Simulation of Adults' Daily Walking Within a City](https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/) +1. A Spatial Agent-Based Model for the Simulation of Adults’ Daily Walking Within a City [article](https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/) From 7de1a1602759d7be58de81cfe60cfe4efb69c76d Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Tue, 31 Dec 2024 19:00:08 +0530 Subject: [PATCH 06/13] improved the model with faster lookups, better placement and better walking behaviour --- .../city_walking_behaviour/agents.py | 309 +++++++++++------- .../city_walking_behaviour/model.py | 129 +++++--- 2 files changed, 278 insertions(+), 160 deletions(-) diff --git a/examples/city_walking_behaviour/city_walking_behaviour/agents.py b/examples/city_walking_behaviour/city_walking_behaviour/agents.py index d9397c8a..19a8c767 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/agents.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/agents.py @@ -1,6 +1,8 @@ import math +from collections import defaultdict from enum import Enum -from typing import Optional +from functools import lru_cache +from typing import List, Optional, Tuple import numpy as np from mesa import Model @@ -63,17 +65,17 @@ def __init__(self, model: Model, cell=None): class WalkingBehaviorModel: - """Encapsulates all walking decisions for a human agent, including distance checks.""" + """Optimized walking behavior model with spatial caching and early termination.""" DAILY_PROBABILITIES = { - ActivityType.GROCERY: 0.25, # Every 4 days on average + ActivityType.GROCERY: 0.25, ActivityType.NON_FOOD_SHOPPING: 0.25, ActivityType.SOCIAL: 0.15, ActivityType.LEISURE: 0.20, } BASE_MAX_DISTANCES = { - ActivityType.WORK: 2000, # meters + ActivityType.WORK: 2000, ActivityType.GROCERY: 1000, ActivityType.NON_FOOD_SHOPPING: 1500, ActivityType.SOCIAL: 2000, @@ -83,143 +85,205 @@ class WalkingBehaviorModel: def __init__(self, model: Model): self.model = model self.total_distance_walked = 0 - - def reset_daily_distance(self): - """Reset the total distance walked for a new day""" + # Spatial index for quick location lookup + self._location_cache = {} + self._distance_cache = {} + # Maximum possible walking distance for any activity + self._max_possible_distance = max(self.BASE_MAX_DISTANCES.values()) + + def reset_daily_distance(self) -> None: + """Reset daily walking distance.""" self.total_distance_walked = 0 - def add_distance(self, distance: float): - """Add distance to total daily walking distance""" + def add_distance(self, distance: float) -> None: + """Add to total daily walking distance.""" self.total_distance_walked += distance - def get_max_walking_distance(self, human, activity: ActivityType) -> float: - """Calculate person-specific maximum walking distance""" - return self.BASE_MAX_DISTANCES[activity] * human.walking_ability - - def calculate_distance(self, cell1, cell2) -> float: - """Calculate distance between two cells""" - x1, y1 = cell1.coordinate - x2, y2 = cell2.coordinate - return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) + @lru_cache(maxsize=1024) # noqa + def get_max_walking_distance(self, ability: float, activity: ActivityType) -> float: + """Cached calculation of max walking distance based on ability.""" + return self.BASE_MAX_DISTANCES[activity] * ability + + @staticmethod + @lru_cache(maxsize=4096) + def calculate_distance(x1: int, y1: int, x2: int, y2: int) -> float: + """Cached distance calculation between two points.""" + dx = x2 - x1 + dy = y2 - y1 + return math.sqrt(dx * dx + dy * dy) + + def get_distance(self, cell1, cell2) -> float: + """Get distance between cells using cache.""" + key = (cell1, cell2) + if key not in self._distance_cache: + x1, y1 = cell1.coordinate + x2, y2 = cell2.coordinate + self._distance_cache[key] = self.calculate_distance(x1, y1, x2, y2) + return self._distance_cache[key] def decide_walk_to_work(self, human) -> bool: - """Decide if person walks to work""" - if not human.is_working or not human.workplace: + """Optimized work walk decision.""" + if not (human.is_working and human.workplace): return False - distance = self.calculate_distance(human.cell, human.workplace.cell) - max_distance = self.get_max_walking_distance(human, ActivityType.WORK) - - if distance <= max_distance: - return self.model.random.random() <= human.walking_attitude - return False - - def find_nearest_location( - self, human, activity_type: ActivityType, search_workplace: bool = True - ) -> Optional[FixedAgent]: - """Find nearest location of given type within walking distance""" - if activity_type == ActivityType.GROCERY: - locations = self.model.agents_by_type[GroceryStore] - elif activity_type == ActivityType.NON_FOOD_SHOPPING: - locations = self.model.agents_by_type[NonFoodShop] - elif activity_type == ActivityType.SOCIAL: - locations = self.model.agents_by_type[SocialPlace] - else: - return None + distance = self.get_distance(human.household, human.workplace.cell) + max_distance = self.get_max_walking_distance( + human.walking_ability, ActivityType.WORK + ) - max_distance = self.get_max_walking_distance(human, activity_type) + return ( + distance <= max_distance + and self.model.random.random() <= human.walking_attitude + ) - # Check locations near home - nearest = min( - locations, - key=lambda loc: self.calculate_distance(human.cell, loc.cell), - default=None, + def _build_location_cache(self, activity_type: ActivityType) -> None: + """Build spatial index for locations of given type.""" + if activity_type not in self._location_cache: + locations = defaultdict(list) + if activity_type == ActivityType.GROCERY: + agents = self.model.agents_by_type[GroceryStore] + elif activity_type == ActivityType.NON_FOOD_SHOPPING: + agents = self.model.agents_by_type[NonFoodShop] + elif activity_type == ActivityType.SOCIAL: + agents = self.model.agents_by_type[SocialPlace] + else: + return + + # Group locations by grid sectors for faster lookup + sector_size = int(self._max_possible_distance) + for agent in agents: + x, y = agent.cell.coordinate + sector_x = x // sector_size + sector_y = y // sector_size + locations[(sector_x, sector_y)].append(agent) + + self._location_cache[activity_type] = locations + + def find_walkable_locations(self, human, activity_type: ActivityType) -> List: + """Find walkable locations using spatial indexing.""" + self._build_location_cache(activity_type) + max_distance = self.get_max_walking_distance( + human.walking_ability, activity_type ) - if ( - nearest - and self.calculate_distance(human.cell, nearest.cell) <= max_distance - ): - return nearest - - # Check locations near workplace if applicable - if search_workplace and human.workplace: - nearest = min( - locations, - key=lambda loc: self.calculate_distance(human.workplace.cell, loc.cell), - default=None, - ) - if ( - nearest - and self.calculate_distance(human.workplace.cell, nearest.cell) - <= max_distance - ): - return nearest - return None + walkable = [] + locations = self._location_cache.get(activity_type, {}) + + # Helper function to check locations near a reference point + def check_near_point(ref_point): + x, y = ref_point.coordinate + sector_size = int(self._max_possible_distance) + sector_x = x // sector_size + sector_y = y // sector_size + + # Check nearby sectors only + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + sector = (sector_x + dx, sector_y + dy) + for location in locations.get(sector, []): + if self.get_distance(ref_point, location.cell) <= max_distance: + walkable.append(location) + return bool(walkable) + + # Check household first + if check_near_point(human.household): + return walkable + + # If no locations found and person is working, check workplace + if human.is_working and human.workplace: + check_near_point(human.workplace.cell) + + return walkable + + def get_leisure_cells(self, human) -> List: + """Get valid leisure walk destinations.""" + max_distance = self.get_max_walking_distance( + human.walking_ability, ActivityType.LEISURE + ) + min_distance = max_distance * 0.75 - def decide_leisure_walk(self, human) -> Optional[tuple[float, float]]: - """Decide if person takes a leisure walk""" + valid_cells = [] + household_x, household_y = human.household.coordinate + + # Set a minimum sector size to avoid division by zero + sector_size = max(int(max_distance), 1) # Ensure minimum size of 1 + sector_x = household_x // sector_size + sector_y = household_y // sector_size + + # Check nearby sectors only + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + x_min = (sector_x + dx) * sector_size + y_min = (sector_y + dy) * sector_size + x_max = x_min + sector_size + y_max = y_min + sector_size + + for cell in self.model.grid.all_cells.cells: + x, y = cell.coordinate + if x_min <= x <= x_max and y_min <= y <= y_max: + dist = self.calculate_distance(household_x, household_y, x, y) + if min_distance <= dist <= max_distance: + valid_cells.append(cell) + + return valid_cells + + def decide_leisure_walk(self, human) -> Optional[Cell]: + """Optimized leisure walk decision.""" if ( self.model.random.random() > self.DAILY_PROBABILITIES[ActivityType.LEISURE] * human.walking_attitude ): return None - max_distance = self.get_max_walking_distance(human, ActivityType.LEISURE) - min_distance = max_distance * 0.75 - - # Generate random point within 75-100% of max walking distance - random_cells = [] - all_cells = self.model.grid.all_cells.cells - if len(all_cells) == 0: - return None - for cell in all_cells: - if ( - self.calculate_distance(cell, human.cell) <= max_distance - and self.calculate_distance(cell, human.cell) >= min_distance - ): - random_cells.append(cell) - - if len(random_cells) == 0: - return None - return self.model.random.choice(random_cells) + valid_cells = self.get_leisure_cells(human) + return self.model.random.choice(valid_cells) if valid_cells else None - def simulate_daily_walks(self, human): - """Simulate a full day of possible walks for the agent.""" + def simulate_daily_walks(self, human) -> List[Tuple]: + """Optimized daily walk simulation.""" walks = [] + random = self.model.random.random # Work walk if self.decide_walk_to_work(human): - distance = self.calculate_distance(human.cell, human.workplace.cell) + distance = self.get_distance(human.household, human.workplace.cell) self.add_distance(distance) walks.append((ActivityType.WORK, human.workplace)) # Basic needs walks - for activity in [ActivityType.GROCERY, ActivityType.NON_FOOD_SHOPPING]: - if self.model.random.random() <= self.DAILY_PROBABILITIES[activity]: - destination = self.find_nearest_location(human, activity) - if destination and self.model.random.random() <= human.walking_attitude: - distance = self.calculate_distance(human.cell, destination.cell) + for activity in (ActivityType.GROCERY, ActivityType.NON_FOOD_SHOPPING): + if random() <= self.DAILY_PROBABILITIES[activity]: + walkable = self.find_walkable_locations(human, activity) + if walkable and random() <= human.walking_attitude: + destination = self.model.random.choice(walkable) + distance = self.get_distance(human.household, destination.cell) self.add_distance(distance) walks.append((activity, destination)) # Social visit - if self.model.random.random() <= self.DAILY_PROBABILITIES[ActivityType.SOCIAL]: + if random() <= self.DAILY_PROBABILITIES[ActivityType.SOCIAL]: social_place = self.model.random.choice( self.model.agents_by_type[SocialPlace] ) - distance = self.calculate_distance(human.cell, social_place.cell) - if ( - distance <= self.get_max_walking_distance(human, ActivityType.SOCIAL) - and self.model.random.random() <= human.walking_attitude - ): - self.add_distance(distance) + max_distance = self.get_max_walking_distance( + human.walking_ability, ActivityType.SOCIAL + ) + + household_distance = self.get_distance(human.household, social_place.cell) + workplace_distance = float("inf") + if human.is_working and human.workplace: + workplace_distance = self.get_distance( + human.workplace.cell, social_place.cell + ) + + min_distance = min(household_distance, workplace_distance) + if min_distance <= max_distance and random() <= human.walking_attitude: + self.add_distance(min_distance) walks.append((ActivityType.SOCIAL, social_place)) # Leisure walk leisure_destination = self.decide_leisure_walk(human) if leisure_destination: - distance = self.calculate_distance(human.cell, leisure_destination) + distance = self.get_distance(human.household, leisure_destination) self.add_distance(distance) walks.append((ActivityType.LEISURE, leisure_destination)) @@ -229,11 +293,19 @@ def simulate_daily_walks(self, human): class Human(CellAgent): """Represents a person with specific attributes and daily walking behavior.""" - def __init__(self, model: Model, unique_id: int = 0, cell=None, SES: int = 0): + def __init__( + self, + model: Model, + unique_id: int = 0, + cell=None, + SES: int = 0, + household: Cell = None, + ): super().__init__(model) self.cell = cell self.unique_id = unique_id self.SES = SES + self.household = household # Human Attributes self.gender = self.model.generate_gender() @@ -262,10 +334,10 @@ def __init__(self, model: Model, unique_id: int = 0, cell=None, SES: int = 0): def _determine_working_status(self) -> bool: if self.age >= RETIREMENT_AGE: return False - return self.model.random.random() < WORKING_PROBABILITY + return self.random.random() < WORKING_PROBABILITY def get_friends(self) -> AgentSet: - friend_count = self.model.random.randint(MIN_FRIENDS, MAX_FRIENDS) + friend_count = self.random.randint(MIN_FRIENDS, MAX_FRIENDS) friend_set = AgentSet.select( self.model.agents_by_type[Human], lambda x: (x.SES > self.SES - 1 and x.SES < self.SES + 1) @@ -286,7 +358,7 @@ def get_family(self) -> AgentSet: at_most=1, ) if len(family_set) > 0: - family_set[0].family = AgentSet([self], random=self.model.random) + family_set[0].family = AgentSet([self], random=self.random) return family_set else: return None @@ -294,12 +366,12 @@ def get_family(self) -> AgentSet: def get_workplace(self) -> Optional[Workplace | FixedAgent]: if not self.is_working: return None - return self.model.random.choice(self.model.agents_by_type[GroceryStore]) + return self.random.choice(self.model.agents_by_type[GroceryStore]) def get_walking_ability( self, ) -> float: # Method from https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/ - random_component = self.model.random.random() ** 4 + random_component = self.random.random() ** 4 if self.age <= 37: # For ages up to 37, use the base calculation return random_component * (min(abs(137 - self.age), 100) / 100) @@ -312,10 +384,11 @@ def get_walking_ability( def get_walking_attitude( self, ) -> float: # Method from https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/ - return self.model.random.random() ** 3 + return self.random.random() ** 3 def get_feedback(self, activity: ActivityType): - a: float = 0.001 # attitude factor + a: float = 0.001 * 20 # attitude factor + # 20 because the model is scaled down 20 times. # 1. Walking attitudes of family members and friends if self.family: @@ -337,13 +410,10 @@ def get_feedback(self, activity: ActivityType): # 2. Person's walking experience x, y = self.cell.coordinate SE_index = ( - ( - self.model.safety_cell_layer.data[x][y] - + self.model.random.uniform(-0.5, 0.5) - ) + (self.model.safety_cell_layer.data[x][y] + self.random.uniform(-0.5, 0.5)) * ( self.model.aesthetic_cell_layer.data[x][y] - + self.model.random.uniform(-0.5, 0.5) + + self.random.uniform(-0.5, 0.5) ) ) / np.mean( self.model.safety_cell_layer.data * self.model.aesthetic_cell_layer.data @@ -367,7 +437,9 @@ def get_feedback(self, activity: ActivityType): walking_feedback = 0 if self.walking_behavior.total_distance_walked > 0: max_personal_distance = ( - self.walking_behavior.get_max_walking_distance(self, activity) + self.walking_behavior.get_max_walking_distance( + self.walking_ability, activity + ) * self.walking_ability ) walking_feedback = min( @@ -395,7 +467,12 @@ def step(self): [ 1 for activity, _ in daily_walks - if activity in [ActivityType.GROCERY, ActivityType.NON_FOOD_SHOPPING] + if activity + in [ + ActivityType.GROCERY, + ActivityType.NON_FOOD_SHOPPING, + ActivityType.SOCIAL, + ] ] ) self.leisure_trips = sum( diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py index 0cdd3dd6..b055f2f6 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/model.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -1,6 +1,6 @@ import math -from city_walking_behaviour.agents import ( +from agents import ( DOG_OWNER_PROBABILITY, FEMALE_PROBABILITY, MAX_AGE, @@ -502,43 +502,51 @@ def _setup_random_safety(self): ) def _setup_central_safety(self): - """Set up safety values with realistic urban-like distribution.""" + """Set up safety values with a subtle urban-like distribution.""" center_x, center_y = self.height // 2, self.width // 2 max_distance = math.sqrt((self.height // 2) ** 2 + (self.width // 2) ** 2) - # Create multiple centers of low safety to simulate urban clusters + # Define centers with proper formatting centers = [ (center_x, center_y), # Main center - ( - center_x + self.height // 4, - center_y + self.width // 4, - ), # Secondary centers - (center_x - self.height // 4, center_y - self.width // 4), - (center_x + self.height // 4, center_y - self.width // 4), - (center_x - self.height // 4, center_y + self.width // 4), + # Secondary Centers: Removed for model simplicity. + # (center_x + self.height // 4, center_y + self.width // 4), # NE + # (center_x - self.height // 4, center_y - self.width // 4), # SW + # (center_x + self.height // 4, center_y - self.width // 4), # SE + # (center_x - self.height // 4, center_y + self.width // 4), # NW ] def calculate_safety(i, j): - distances = [math.sqrt((i - cx) ** 2 + (j - cy) ** 2) for cx, cy in centers] + # Calculate Manhattan distance + distances = [max(abs(i - cx), abs(j - cy)) for cx, cy in centers] min_distance = min(distances) / max_distance - # Base safety increases with distance from centers - base_safety = min_distance * 0.8 # Maximum safety of 0.8 - - # Add some local variation - local_variation = self.random.random() * 0.2 - - # Add some urban-like patterns - if min_distance < 0.3: # Near centers - # More variation in central areas - safety = base_safety + local_variation - else: # Suburban and outer areas - # More consistent safety levels - safety = base_safety + (local_variation * 0.5) + # Subtle base safety calculation for center + base_safety = 0.4 + (min_distance * 0.4) + + # Square influence for center + square_factor = abs((i - center_x) / self.height) + abs( + (j - center_y) / self.width + ) + central_damping = 1 - max(0, 0.7 - min_distance) # Reduce effect in center + square_influence = ( + square_factor * 0.15 * central_damping + ) # Reduced overall influence + + # Smaller local variation + local_variation = self.random.random() * 0.03 + + # Subtle transition in center + if min_distance < 0.3: + safety = ( + base_safety + (square_influence * 0.5) + (local_variation * 0.5) + ) + else: + safety = base_safety + square_influence + (local_variation * 0.4) - # safety stays within [0, 1] - return max(0.1, min(1.0, safety)) + return max(0.3, min(0.85, safety)) # Narrower range for more subtlety + # Fill the safety layer for i in range(self.height): for j in range(self.width): self.safety_cell_layer.data[i][j] = calculate_safety(i, j) @@ -560,52 +568,85 @@ def _apply_central_safe(self): self._setup_central_safety() def add_initial_humans(self): - """Add initial humans with distance-based cell organization.""" + """Add initial humans with distance-based cell organization, allowing for occupied cells when necessary.""" center_x, center_y = self.height // 2, self.width // 2 max_distance = math.sqrt((self.height // 2) ** 2 + (self.width // 2) ** 2) # Initialize dictionary for each SES level cells_with_proximity = {1: [], 2: [], 3: [], 4: [], 5: []} - - # Categorize all empty cells based on their distance from center + all_cells_by_ses = { + 1: [], + 2: [], + 3: [], + 4: [], + 5: [], + } # Including occupied cells + + # Categorize all cells based on their distance from center for cell in self.grid.all_cells: - if not cell.empty: - continue - x, y = cell.coordinate distance = math.sqrt((x - center_x) ** 2 + (y - center_y) ** 2) normalized_distance = distance / max_distance # Assign to SES levels based on normalized distance if normalized_distance < 0.2: - cells_with_proximity[1].append(cell) + ses_level = 1 elif normalized_distance < 0.4: - cells_with_proximity[2].append(cell) + ses_level = 2 elif normalized_distance < 0.6: - cells_with_proximity[3].append(cell) + ses_level = 3 elif normalized_distance < 0.8: - cells_with_proximity[4].append(cell) + ses_level = 4 else: - cells_with_proximity[5].append(cell) + ses_level = 5 + + # Add to both lists - one for empty cells, one for all cells + all_cells_by_ses[ses_level].append(cell) + if cell.empty: + cells_with_proximity[ses_level].append(cell) # Place couples for _ in range(self.no_of_couples): ses = self.generate_ses() - if cells_with_proximity[ses] and len(cells_with_proximity[ses]) >= 2: - cell = self.random.choice(cells_with_proximity[ses]) - cells_with_proximity[ses].remove(cell) + + # Try to place in empty cells first + if len(cells_with_proximity[ses]) >= 2: + cells = cells_with_proximity[ses] + # If not enough empty cells, use any cells in the correct SES area + elif len(all_cells_by_ses[ses]) >= 2: + cells = all_cells_by_ses[ses] + # If still no cells available, use any cells on the grid + else: + cells = self.grid.all_cells + + if cells: + cell = self.random.choice(cells) + if cell in cells_with_proximity[ses]: + cells_with_proximity[ses].remove(cell) # Create the couple for _ in range(2): - Human(self, self.unique_id, cell, SES=ses) + Human(self, self.unique_id, cell, SES=ses, household=cell) self.unique_id += 1 # Place singles for _ in range(self.no_of_singles): ses = self.generate_ses() + + # Try to place in empty cells first if cells_with_proximity[ses]: - cell = self.random.choice(cells_with_proximity[ses]) - cells_with_proximity[ses].remove(cell) - Human(self, self.unique_id, cell, SES=ses) + cells = cells_with_proximity[ses] + # If no empty cells, use any cells in the correct SES area + elif all_cells_by_ses[ses]: + cells = all_cells_by_ses[ses] + # If still no cells available, use any cells on the grid + else: + cells = self.grid.all_cells + + if cells: + cell = self.random.choice(cells) + if cell in cells_with_proximity[ses]: + cells_with_proximity[ses].remove(cell) + Human(self, self.unique_id, cell, SES=ses, household=cell) self.unique_id += 1 def step(self): From 32207b2d15592d7de66f0bfcfb14d3b3fc2e04e0 Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Thu, 2 Jan 2025 15:01:29 +0530 Subject: [PATCH 07/13] fixed leisure walks, improved household logic and placement for Humans. --- .../city_walking_behaviour/agents.py | 249 ++++++++++-------- .../city_walking_behaviour/model.py | 186 ++++++++----- 2 files changed, 261 insertions(+), 174 deletions(-) diff --git a/examples/city_walking_behaviour/city_walking_behaviour/agents.py b/examples/city_walking_behaviour/city_walking_behaviour/agents.py index 19a8c767..661dc3eb 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/agents.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/agents.py @@ -67,19 +67,20 @@ def __init__(self, model: Model, cell=None): class WalkingBehaviorModel: """Optimized walking behavior model with spatial caching and early termination.""" + MILES_TO_METERS = 1609.34 DAILY_PROBABILITIES = { - ActivityType.GROCERY: 0.25, - ActivityType.NON_FOOD_SHOPPING: 0.25, - ActivityType.SOCIAL: 0.15, - ActivityType.LEISURE: 0.20, + ActivityType.GROCERY: 0.4, + ActivityType.NON_FOOD_SHOPPING: 0.25, # once every 4 days + ActivityType.SOCIAL: 0.20, + ActivityType.LEISURE: 0.33, } BASE_MAX_DISTANCES = { - ActivityType.WORK: 2000, - ActivityType.GROCERY: 1000, - ActivityType.NON_FOOD_SHOPPING: 1500, - ActivityType.SOCIAL: 2000, - ActivityType.LEISURE: 3000, + ActivityType.WORK: 1.125 * MILES_TO_METERS, # meters + ActivityType.GROCERY: 2.000 * MILES_TO_METERS, + ActivityType.NON_FOOD_SHOPPING: 1.500 * MILES_TO_METERS, + ActivityType.SOCIAL: 2.500 * MILES_TO_METERS, + ActivityType.LEISURE: 5.500 * MILES_TO_METERS, } def __init__(self, model: Model): @@ -195,48 +196,67 @@ def check_near_point(ref_point): return walkable - def get_leisure_cells(self, human) -> List: - """Get valid leisure walk destinations.""" + def get_leisure_cells(self, human) -> List[Cell]: + """ + Get valid leisure walk destinations. + """ + if not human or not human.household: + return [] + + # Calculate distances based on walking ability max_distance = self.get_max_walking_distance( human.walking_ability, ActivityType.LEISURE ) + # Set minimum distance to 75% of max distance min_distance = max_distance * 0.75 - valid_cells = [] household_x, household_y = human.household.coordinate + valid_cells = [] + + for cell in self.model.grid.all_cells.cells: + x, y = cell.coordinate - # Set a minimum sector size to avoid division by zero - sector_size = max(int(max_distance), 1) # Ensure minimum size of 1 - sector_x = household_x // sector_size - sector_y = household_y // sector_size - - # Check nearby sectors only - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - x_min = (sector_x + dx) * sector_size - y_min = (sector_y + dy) * sector_size - x_max = x_min + sector_size - y_max = y_min + sector_size - - for cell in self.model.grid.all_cells.cells: - x, y = cell.coordinate - if x_min <= x <= x_max and y_min <= y <= y_max: - dist = self.calculate_distance(household_x, household_y, x, y) - if min_distance <= dist <= max_distance: - valid_cells.append(cell) + # Quick boundary check + if ( + abs(x - household_x) > max_distance + or abs(y - household_y) > max_distance + ): + continue + + # Calculate exact distance + dist = self.calculate_distance(household_x, household_y, x, y) + if min_distance <= dist <= max_distance: + valid_cells.append(cell) + + if len(valid_cells) >= 200: + return valid_cells return valid_cells def decide_leisure_walk(self, human) -> Optional[Cell]: - """Optimized leisure walk decision.""" - if ( - self.model.random.random() - > self.DAILY_PROBABILITIES[ActivityType.LEISURE] * human.walking_attitude - ): + """ + Leisure walk decision making. + """ + base_probability = self.DAILY_PROBABILITIES[ActivityType.LEISURE] + + # Consider additional factors that might encourage walking + motivation_factors = 1.0 + if human.has_dog: # Dog owners are more likely to take leisure walks + motivation_factors += 0.3 + if not human.is_working: # Non-working individuals have more time + motivation_factors += 0.2 + + # Final probability calculation + probability = base_probability * human.walking_attitude * motivation_factors + + if self.model.random.random() > probability: return None valid_cells = self.get_leisure_cells(human) - return self.model.random.choice(valid_cells) if valid_cells else None + if not valid_cells: + return None + + return self.model.random.choice(valid_cells) def simulate_daily_walks(self, human) -> List[Tuple]: """Optimized daily walk simulation.""" @@ -289,6 +309,29 @@ def simulate_daily_walks(self, human) -> List[Tuple]: return walks + def __repr__(self) -> str: + """ + Return a detailed string representation of the WalkingBehaviorModel. + + Returns: + str: String showing model state including caches and distances + """ + cache_stats = { + "location_cache_size": sum( + len(locations) for locations in self._location_cache.values() + ), + "distance_cache_size": len(self._distance_cache), + "leisure_cache_size": len(getattr(self, "_leisure_cells_cache", {})), + } + + return ( + f"WalkingBehaviorModel(" + f"total_distance_walked={self.total_distance_walked:.2f}, " + f"max_possible_distance={self._max_possible_distance}, " + f"cache_sizes={cache_stats}, " + f"daily_probabilities={len(self.DAILY_PROBABILITIES)} activities)" + ) + class Human(CellAgent): """Represents a person with specific attributes and daily walking behavior.""" @@ -296,31 +339,33 @@ class Human(CellAgent): def __init__( self, model: Model, + gender: Optional[int] = None, + family_size: Optional[int] = None, + age: Optional[int] = None, + SES: Optional[int] = None, unique_id: int = 0, cell=None, - SES: int = 0, household: Cell = None, ): super().__init__(model) self.cell = cell self.unique_id = unique_id - self.SES = SES self.household = household # Human Attributes - self.gender = self.model.generate_gender() - self.age = self.model.generate_age() - self.family_size = self.model.generate_family_size() + self.gender = gender + self.age = age + self.SES = SES + self.family_size = family_size self.has_dog = self.model.generate_dog_ownership() self.walking_ability = self.get_walking_ability() self.walking_attitude = self.get_walking_attitude() self.is_working = self._determine_working_status() self.workplace = self.get_workplace() self.friends = self.get_friends() - self.family = self.get_family() + self.family: Human = None self.previous_walking_density: float = 0 - self.current_walking_density: float # Datacollector attributes self.daily_walking_trips: int = 0 @@ -340,33 +385,28 @@ def get_friends(self) -> AgentSet: friend_count = self.random.randint(MIN_FRIENDS, MAX_FRIENDS) friend_set = AgentSet.select( self.model.agents_by_type[Human], - lambda x: (x.SES > self.SES - 1 and x.SES < self.SES + 1) + lambda x: ( + x.SES > self.SES - 2 and x.SES < self.SES + 2 + ) # get friends with similar SES i.e. difference no more than 3 and x.unique_id != self.unique_id, at_most=friend_count, ) if len(friend_set) > 0: for friend in friend_set: - friend.friends.add(self) + friend.friends.add(self) # add self to the friends list as well return friend_set - def get_family(self) -> AgentSet: - if self.family_size > 1: - family_set = AgentSet.select( - self.model.agents_by_type[Human], - lambda x: x.gender != self.gender - and abs(x.age - self.age) <= 3, # age difference no more than 3 years - at_most=1, - ) - if len(family_set) > 0: - family_set[0].family = AgentSet([self], random=self.random) - return family_set - else: - return None - def get_workplace(self) -> Optional[Workplace | FixedAgent]: if not self.is_working: return None - return self.random.choice(self.model.agents_by_type[GroceryStore]) + + # Get all workplaces like grocery stores, non-food shops, social places + all_workplaces = [ + workplace + for workplace in self.model.agents + if not isinstance(workplace, Human) + ] + return self.random.choice(all_workplaces) def get_walking_ability( self, @@ -386,72 +426,69 @@ def get_walking_attitude( ) -> float: # Method from https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/ return self.random.random() ** 3 - def get_feedback(self, activity: ActivityType): - a: float = 0.001 * 20 # attitude factor - # 20 because the model is scaled down 20 times. + def get_feedback(self): + a: float = 0.001 # attitude factor + + # 1. Social network feedback (family and friends) + # Store original attitude for use in calculations + At = self.walking_attitude - # 1. Walking attitudes of family members and friends + # Family feedback (Equations 1 & 2 in literature) if self.family: - self.walking_attitude = ((1 - a) * self.walking_attitude) + ( - a * self.family[0].walking_attitude - ) + self.walking_attitude = (1 - a) * At + a * self.family.walking_attitude + # Friends feedback (Equation 3 in literature) if self.friends: - cumulative_friends_attitude: float = 0 # Initialize to 0 - for friend in self.friends: - cumulative_friends_attitude += friend.walking_attitude - # Average the friends' attitudes if there are any + friends_attitude = sum(friend.walking_attitude for friend in self.friends) if len(self.friends) > 0: - cumulative_friends_attitude /= len(self.friends) - self.walking_attitude = ((1 - a) * self.walking_attitude) + ( - a * cumulative_friends_attitude - ) + friends_attitude /= len(self.friends) + self.walking_attitude = (1 - a) * At + a * friends_attitude - # 2. Person's walking experience + # 2. Walking experience feedback (Equation 4 in literature) x, y = self.cell.coordinate SE_index = ( - (self.model.safety_cell_layer.data[x][y] + self.random.uniform(-0.5, 0.5)) + ( + self.model.safety_cell_layer.data[x][y] + + self.model.random.uniform(-0.5, 0.5) + ) * ( self.model.aesthetic_cell_layer.data[x][y] - + self.random.uniform(-0.5, 0.5) + + self.model.random.uniform(-0.5, 0.5) ) ) / np.mean( self.model.safety_cell_layer.data * self.model.aesthetic_cell_layer.data ) - # 3. Density of other walkers - neighbour_cells = self.cell.get_neighborhood(radius=2) - num_neighbours = [i for i in neighbour_cells if i.agents] - self.current_walking_density = len(num_neighbours) / len(neighbour_cells) - density_feedback = 0 - if self.previous_walking_density == 0: - # If previous density was zero, treat any current density as a positive change - density_feedback = 1 if self.current_walking_density > 0 else 0 + # 3. Density feedback + # Compare current walking density to previous day + neighbour_cells = self.cell.get_neighborhood(radius=1) + current_density = sum(len(cell.agents) for cell in neighbour_cells) / len( + neighbour_cells + ) + + Id = 0 + if self.previous_walking_density > 0: + Id = current_density / self.previous_walking_density else: - density_ratio = self.current_walking_density / self.previous_walking_density - density_feedback = density_ratio - 1 # Centers the feedback around 0 + Id = 1 if current_density > 0 else 0 - self.previous_walking_density = self.current_walking_density + self.previous_walking_density = current_density - # 4. Total amount walked by the person during that day - walking_feedback = 0 + # 4. Walking distance feedback (Equation 5 in literature) + It = 0 if self.walking_behavior.total_distance_walked > 0: - max_personal_distance = ( - self.walking_behavior.get_max_walking_distance( - self.walking_ability, activity - ) - * self.walking_ability - ) - walking_feedback = min( - 1, max_personal_distance / self.walking_behavior.total_distance_walked + Ab_Da = sum( + [ + dis * self.walking_ability + for dis in self.walking_behavior.BASE_MAX_DISTANCES.values() + ] ) + d = self.walking_behavior.total_distance_walked + It = min(1, Ab_Da / d) - # Update walking attitude + # Final attitude update (Equation 6 in literature) self.walking_attitude = ( - self.walking_attitude - * (1 - a + (a * SE_index)) - * (1 - a + (a * density_feedback)) - * (1 - a + (a * walking_feedback)) + At * (1 - a + a * SE_index) * (1 - a + a * Id) * (1 - a + a * It) ) def step(self): @@ -480,8 +517,8 @@ def step(self): ) if len(daily_walks) > 0: + self.get_feedback() for activity, destination in daily_walks: - self.get_feedback(activity) # Move agent to new cell if applicable if isinstance(destination, FixedAgent): self.cell = destination.cell diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py index b055f2f6..19ac9f53 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/model.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -510,10 +510,10 @@ def _setup_central_safety(self): centers = [ (center_x, center_y), # Main center # Secondary Centers: Removed for model simplicity. - # (center_x + self.height // 4, center_y + self.width // 4), # NE - # (center_x - self.height // 4, center_y - self.width // 4), # SW - # (center_x + self.height // 4, center_y - self.width // 4), # SE - # (center_x - self.height // 4, center_y + self.width // 4), # NW + # (center_x + self.height // 4, center_y + self.width // 4), # NE + # (center_x - self.height // 4, center_y - self.width // 4), # SW + # (center_x + self.height // 4, center_y - self.width // 4), # SE + # (center_x - self.height // 4, center_y + self.width // 4), # NW ] def calculate_safety(i, j): @@ -521,10 +521,12 @@ def calculate_safety(i, j): distances = [max(abs(i - cx), abs(j - cy)) for cx, cy in centers] min_distance = min(distances) / max_distance - # Subtle base safety calculation for center - base_safety = 0.4 + (min_distance * 0.4) + # More subtle base safety calculation for center + base_safety = 0.4 + ( + min_distance * 0.4 + ) # Reduced range and increased minimum - # Square influence for center + # Reduced square influence for center square_factor = abs((i - center_x) / self.height) + abs( (j - center_y) / self.width ) @@ -534,9 +536,9 @@ def calculate_safety(i, j): ) # Reduced overall influence # Smaller local variation - local_variation = self.random.random() * 0.03 + local_variation = self.random.random() * 0.03 # Reduced random variation - # Subtle transition in center + # More subtle transition in center if min_distance < 0.3: safety = ( base_safety + (square_influence * 0.5) + (local_variation * 0.5) @@ -568,27 +570,22 @@ def _apply_central_safe(self): self._setup_central_safety() def add_initial_humans(self): - """Add initial humans with distance-based cell organization, allowing for occupied cells when necessary.""" + """Add initial humans with distance-based cell organization. Each cell can contain up to 'n' households. + Couples share households.""" center_x, center_y = self.height // 2, self.width // 2 max_distance = math.sqrt((self.height // 2) ** 2 + (self.width // 2) ** 2) + MAX_HOUSEHOLDS_PER_CELL = 10 + + # Track available cells and their SES levels along with current occupancy + cells_by_ses = {} + cell_occupancy = {} # Track number of households in each cell - # Initialize dictionary for each SES level - cells_with_proximity = {1: [], 2: [], 3: [], 4: [], 5: []} - all_cells_by_ses = { - 1: [], - 2: [], - 3: [], - 4: [], - 5: [], - } # Including occupied cells - - # Categorize all cells based on their distance from center for cell in self.grid.all_cells: x, y = cell.coordinate distance = math.sqrt((x - center_x) ** 2 + (y - center_y) ** 2) normalized_distance = distance / max_distance - # Assign to SES levels based on normalized distance + # Assign SES based on normalized distance from center if normalized_distance < 0.2: ses_level = 1 elif normalized_distance < 0.4: @@ -600,53 +597,107 @@ def add_initial_humans(self): else: ses_level = 5 - # Add to both lists - one for empty cells, one for all cells - all_cells_by_ses[ses_level].append(cell) - if cell.empty: - cells_with_proximity[ses_level].append(cell) - - # Place couples + if ses_level not in cells_by_ses: + cells_by_ses[ses_level] = [] + cells_by_ses[ses_level].append(cell) + cell_occupancy[cell] = 0 # Initialize occupancy counter + + def find_available_cell(ses): + """Helper function to find a cell that hasn't reached maximum capacity""" + # Try in preferred SES zone first + available_cells = [ + cell + for cell in cells_by_ses.get(ses, []) + if cell_occupancy[cell] < MAX_HOUSEHOLDS_PER_CELL + ] + + if available_cells: + return self.random.choice(available_cells) + + # Look in adjacent SES zones (alternate between higher and lower) + for offset in range(1, 5): + # Try higher SES + if ses + offset <= 5: + available_cells = [ + cell + for cell in cells_by_ses.get(ses + offset, []) + if cell_occupancy[cell] < MAX_HOUSEHOLDS_PER_CELL + ] + if available_cells: + return self.random.choice(available_cells) + + # Try lower SES + if ses - offset >= 1: + available_cells = [ + cell + for cell in cells_by_ses.get(ses - offset, []) + if cell_occupancy[cell] < MAX_HOUSEHOLDS_PER_CELL + ] + if available_cells: + return self.random.choice(available_cells) + + return None + + # Place couples first (they share the same household) for _ in range(self.no_of_couples): ses = self.generate_ses() + cell = find_available_cell(ses) - # Try to place in empty cells first - if len(cells_with_proximity[ses]) >= 2: - cells = cells_with_proximity[ses] - # If not enough empty cells, use any cells in the correct SES area - elif len(all_cells_by_ses[ses]) >= 2: - cells = all_cells_by_ses[ses] - # If still no cells available, use any cells on the grid - else: - cells = self.grid.all_cells - - if cells: - cell = self.random.choice(cells) - if cell in cells_with_proximity[ses]: - cells_with_proximity[ses].remove(cell) - # Create the couple - for _ in range(2): - Human(self, self.unique_id, cell, SES=ses, household=cell) - self.unique_id += 1 - - # Place singles - for _ in range(self.no_of_singles): - ses = self.generate_ses() + if cell: + # Create both members of the couple in the same household + household = cell # Using cell as household identifier + cell_occupancy[cell] += ( + 1 # Increment occupancy (couples count as one household) + ) - # Try to place in empty cells first - if cells_with_proximity[ses]: - cells = cells_with_proximity[ses] - # If no empty cells, use any cells in the correct SES area - elif all_cells_by_ses[ses]: - cells = all_cells_by_ses[ses] - # If still no cells available, use any cells on the grid - else: - cells = self.grid.all_cells + male = Human( + self, + gender="Male", + family_size=2, + age=self.generate_age(), + SES=ses, + unique_id=self.unique_id, + cell=cell, + household=cell, + ) + self.unique_id += 1 + female = Human( + self, + gender="Female", + family_size=2, + age=self.random.randint( + male.age - 3, male.age + 3 + ), # selecting age with difference no more than 3. + SES=ses, + unique_id=self.unique_id, + cell=cell, + household=cell, + ) + self.unique_id += 1 + + # Link the couple together + male.family = female + female.family = male - if cells: - cell = self.random.choice(cells) - if cell in cells_with_proximity[ses]: - cells_with_proximity[ses].remove(cell) - Human(self, self.unique_id, cell, SES=ses, household=cell) + # Place singles (each gets their own household) + for _ in range(self.no_of_singles): + ses = self.generate_ses() + cell = find_available_cell(ses) + + if cell: + household = cell # Using cell as household identifier + cell_occupancy[cell] += 1 # Increment occupancy + + Human( + self, + gender=self.generate_gender(), + family_size=1, + age=self.generate_age(), + SES=ses, + unique_id=self.unique_id, + cell=cell, + household=household, + ) self.unique_id += 1 def step(self): @@ -655,10 +706,9 @@ def step(self): # Reset daily walking trips self.agents_by_type[Human].daily_walking_trips = 0 - self.daily_walking_trips = 0 - self.work_trips = 0 - self.basic_needs_trips = 0 - self.leisure_trips = 0 + self.agents_by_type[Human].work_trips = 0 + self.agents_by_type[Human].basic_needs_trips = 0 + self.agents_by_type[Human].leisure_trips = 0 self.datacollector.collect(self) From 9159ee19f285acec1683d6b2a2478e51178f344a Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Thu, 2 Jan 2025 15:08:05 +0530 Subject: [PATCH 08/13] fixed imports in model.py --- examples/city_walking_behaviour/city_walking_behaviour/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py index 19ac9f53..c462bba6 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/model.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -1,6 +1,6 @@ import math -from agents import ( +from city_walking_behaviour.agents import ( DOG_OWNER_PROBABILITY, FEMALE_PROBABILITY, MAX_AGE, From 7fd55598218c4618c6d837e62da01394a6ff97ea Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Fri, 3 Jan 2025 10:13:18 +0530 Subject: [PATCH 09/13] fix: model can pass tests --- .../city_walking_behaviour/__init__.py | 0 .../city_walking_behaviour/model.py | 13 +++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 examples/city_walking_behaviour/city_walking_behaviour/__init__.py diff --git a/examples/city_walking_behaviour/city_walking_behaviour/__init__.py b/examples/city_walking_behaviour/city_walking_behaviour/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py index c462bba6..461bce04 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/model.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -1,6 +1,12 @@ import math -from city_walking_behaviour.agents import ( +from mesa import Model +from mesa.datacollection import DataCollector +from mesa.experimental.cell_space import OrthogonalVonNeumannGrid +from mesa.experimental.cell_space.property_layer import PropertyLayer +from mesa.experimental.devs import ABMSimulator + +from .agents import ( DOG_OWNER_PROBABILITY, FEMALE_PROBABILITY, MAX_AGE, @@ -12,11 +18,6 @@ Other, SocialPlace, ) -from mesa import Model -from mesa.datacollection import DataCollector -from mesa.experimental.cell_space import OrthogonalVonNeumannGrid -from mesa.experimental.cell_space.property_layer import PropertyLayer -from mesa.experimental.devs import ABMSimulator SCENARIOS = { "random_random": "Random Land Use, Random Safety", From f32b04e4a2489a3c21dbd5a63a5487d950ce3b18 Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Sat, 4 Jan 2025 11:53:05 +0530 Subject: [PATCH 10/13] updated readme --- examples/city_walking_behaviour/Readme.md | 146 ++++++++++------------ 1 file changed, 67 insertions(+), 79 deletions(-) diff --git a/examples/city_walking_behaviour/Readme.md b/examples/city_walking_behaviour/Readme.md index 1c78827d..ce0e1a86 100644 --- a/examples/city_walking_behaviour/Readme.md +++ b/examples/city_walking_behaviour/Readme.md @@ -1,121 +1,109 @@ # Walking Behavior Agent-Based Model -This repository contains an agent-based model (ABM) that simulates walking behavior in a hypothetical city, examining how socioeconomic status (SES), built environment, and social factors influence walking patterns. +An agent-based model (ABM) that simulates how people walk in cities based on socioeconomic status, environment, and social factors. -# Walking Behavior Simulation Model Documentation +## What This Model Shows -## Overview +This simulation demonstrates how different city layouts and social factors affect walking patterns. Key insights include: -## Model Architecture (`model.py`) +- How safety perceptions influence route choices +- Impact of socioeconomic status on walking frequency +- Effects of centralized vs distributed city planning +- Relationship between social networks and walking behavior -### Initialization Parameters +## Features -- Grid dimensions (width and height) -- Number of workplaces (categorized into Grocery Stores, Social Places, etc.) -- Population composition (number of couples and singles) +- Four simulation scenarios: -### Simulation Scenarios + - Random layout with random safety (RR) + - Random layout with core safety gradient (RS) + - Centralized layout with random safety (CR) + - Centralized layout with core safety gradient (CS) -The model implements four distinct scenarios: +- Agent behaviors include: + - Work commutes + - Shopping trips + - Social visits + - Leisure walks -1. **RR (Random-Random)**: Random land use distribution with random safety values -2. **RS (Random-Safety)**: Random land use distribution with lower safety values in core areas -3. **CR (Centralized-Random)**: Centralized land use with random safety values -4. **CS (Centralized-Safety)**: Centralized land use with lower safety values in core areas +## Outcomes in different scenarios -### Environmental Layers +#### RR -1. **Safety Layer** (`safety_cell_layer`) + - - - Values vary based on selected scenario - - Impacts walking behavior and route choices +#### RS -2. **Aesthetic Layer** (`aesthetic_cell_layer`) - - Values decrease with distance from center - - Reflects personal preferences in route selection + - -### Agent Placement +#### CR -- Workplaces are distributed according to scenario parameters -- Households serve as spawn locations for human agents -- Agent placement correlates with Socioeconomic Status (SES) - lower SES values correspond to more central locations + - -### Data Collection +#### CS -The model tracks the following metrics across five SES levels (1-5): + - -1. Average daily walking trips -2. Work-related trips -3. Basic needs trips (grocery and non-food shopping) -4. Leisure trips (non-purposeful neighborhood walks) - -## Agent Implementation (`agents.py`) - -### Human Class +## Quick Start -Extends the CellAgent class with the following attributes: +1. Run the simulation: -#### Demographic Characteristics +```python +solara run app.py +``` -- Gender: Equal probability of male/female -- Age: Random distribution (18-87 years) -- Family Size: 1 or 2 (based on `SINGLE_HOUSEHOLD_PROBABILITY`) -- Pet Ownership: 20% probability of dog ownership (increases leisure walking frequency) +## Technical Details -#### Personal Attributes +### Model Architecture (`model.py`) -- Walking Ability: Determined by `get_walking_ability` function -- Walking Attitude: Calculated via `get_walking_attitude` function -- Employment Status: - - Automatic retirement above `RETIREMENT_AGE` - - 95% employment probability for working-age population -- Social Network: Maintains lists of friends and family members +#### Initialization Parameters -#### Behavioral Feedback System +- Grid dimensions (width and height) +- Number of workplaces (categorized into Grocery Stores, Social Places, etc.) +- Population composition (number of couples and singles) -Walking attitude is influenced by: +#### Environmental Layers -- Social network (family and friends' attitudes) -- Environmental factors (safety and aesthetics) -- Local pedestrian density -- Cumulative walking distance - -### WalkingBehaviourModel Class +1. **Safety Layer** (`safety_cell_layer`) -Manages walking behavior simulation with: + - Values vary based on selected scenario + - Impacts walking behavior and route choices -- Activity probability distributions -- Maximum distance thresholds -- Daily walk scheduling based on destination distances -- Activity and destination planning algorithms +2. **Aesthetic Layer** (`aesthetic_cell_layer`) + - Values decrease with distance from center + - Reflects personal preferences in route selection -### Workplace Classes +### Agent Implementation (`agents.py`) -A hierarchy of workplace types: +#### Human Class Attributes -1. **Base Workplace**: Abstract class for workplace definition -2. **GroceryStore**: Essential food retail -3. **NonFoodShop**: General retail -4. **SocialPlace**: Community gathering locations -5. **Other**: Miscellaneous workplace types +- **Demographics**: Gender, Age (18-87), Family Size (1-2), Pet Ownership (20% probability) +- **Personal**: Walking Ability, Walking Attitude, Employment Status +- **Social**: Network of friends and family members -All workplace classes inherit from both `Workplace` and `FixedAgent` base classes. +#### Behavioral System -## How to Run +Walking attitude influenced by: -To run a basic simulation: +- Social network (family and friends' attitudes) +- Environmental factors (safety and aesthetics) +- Local pedestrian density +- Cumulative walking distance -```python +### Data Collection -solara run app.py +Tracks metrics across five SES levels (1-5): -``` +1. Average daily walking trips +2. Work-related trips +3. Basic needs trips +4. Leisure trips -# Files +## File Structure -- [city_walking_behaviour/model.py](city_walking_behaviour/model.py): Core model file. -- [city_walking_behaviour/agents.py](city_walking_behaviour/agents.py): The agent class. +- `city_walking_behaviour/model.py`: Core simulation engine +- `city_walking_behaviour/agents.py`: Agent behavior definitions -## Further Reading +## Based On -1. A Spatial Agent-Based Model for the Simulation of Adults’ Daily Walking Within a City [article](https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/) +This model extends research from ["A Spatial Agent-Based Model for the Simulation of Adults' Daily Walking Within a City"](https://pmc.ncbi.nlm.nih.gov/articles/PMC3306662/) From d6b1e0f46d3117741842ca9224a7b7cb03044f28 Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Sat, 4 Jan 2025 12:00:01 +0530 Subject: [PATCH 11/13] added pictures --- examples/city_walking_behaviour/Readme.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/examples/city_walking_behaviour/Readme.md b/examples/city_walking_behaviour/Readme.md index ce0e1a86..082472c7 100644 --- a/examples/city_walking_behaviour/Readme.md +++ b/examples/city_walking_behaviour/Readme.md @@ -29,20 +29,16 @@ This simulation demonstrates how different city layouts and social factors affec ## Outcomes in different scenarios #### RR - - - +- #### RS - - - +![image](https://github.com/user-attachments/assets/67112dbb-056d-4bd5-b668-a1bb389c262d) #### CR - - - +![image](https://github.com/user-attachments/assets/aee37477-7693-46a8-848e-5a5986db0e95) #### CS - - - +![image](https://github.com/user-attachments/assets/f9e58239-19a2-4fa4-bbea-2b7ccbe36c8f) ## Quick Start From 859c9f0dbbe34a33b7bf98530aad61bfe93cbef4 Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Sat, 4 Jan 2025 14:16:36 +0530 Subject: [PATCH 12/13] Update Readme.md --- examples/city_walking_behaviour/Readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/city_walking_behaviour/Readme.md b/examples/city_walking_behaviour/Readme.md index 082472c7..1f468969 100644 --- a/examples/city_walking_behaviour/Readme.md +++ b/examples/city_walking_behaviour/Readme.md @@ -29,7 +29,7 @@ This simulation demonstrates how different city layouts and social factors affec ## Outcomes in different scenarios #### RR -- +![image](https://github.com/user-attachments/assets/fddda37d-d0d5-40f7-9e6d-89fa9b37898a) #### RS ![image](https://github.com/user-attachments/assets/67112dbb-056d-4bd5-b668-a1bb389c262d) From ba5a4be206dce2b7dc5b33ff5f9492e03af4e0f2 Mon Sep 17 00:00:00 2001 From: Sahil Chhoker Date: Sat, 11 Jan 2025 11:45:10 +0530 Subject: [PATCH 13/13] applied reviewed changes --- examples/city_walking_behaviour/app.py | 5 +- .../city_walking_behaviour/agents.py | 18 +- .../city_walking_behaviour/model.py | 259 +++--------------- 3 files changed, 57 insertions(+), 225 deletions(-) diff --git a/examples/city_walking_behaviour/app.py b/examples/city_walking_behaviour/app.py index 2037206a..925f8cd2 100644 --- a/examples/city_walking_behaviour/app.py +++ b/examples/city_walking_behaviour/app.py @@ -6,7 +6,6 @@ SocialPlace, ) from city_walking_behaviour.model import WalkingModel -from mesa.experimental.devs import ABMSimulator from mesa.visualization import ( SolaraViz, make_plot_component, @@ -323,8 +322,7 @@ def post_process_buildings_legend(ax): ) # Initialize and run the model -simulator = ABMSimulator() -model = WalkingModel(simulator=simulator) +model = WalkingModel() # server = mesa.visualization( # WalkingModel, @@ -345,6 +343,5 @@ def post_process_buildings_legend(ax): ], model_params=model_params, name="Walking Model", - simulator=simulator, ) page # noqa diff --git a/examples/city_walking_behaviour/city_walking_behaviour/agents.py b/examples/city_walking_behaviour/city_walking_behaviour/agents.py index 661dc3eb..1209ea4a 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/agents.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/agents.py @@ -64,7 +64,7 @@ def __init__(self, model: Model, cell=None): self.cell = cell -class WalkingBehaviorModel: +class DailyWalkingBehaviour: """Optimized walking behavior model with spatial caching and early termination.""" MILES_TO_METERS = 1609.34 @@ -311,7 +311,7 @@ def simulate_daily_walks(self, human) -> List[Tuple]: def __repr__(self) -> str: """ - Return a detailed string representation of the WalkingBehaviorModel. + Return a detailed string representation of the DailyWalkingBehaviour. Returns: str: String showing model state including caches and distances @@ -325,7 +325,7 @@ def __repr__(self) -> str: } return ( - f"WalkingBehaviorModel(" + f"DailyWalkingBehaviour(" f"total_distance_walked={self.total_distance_walked:.2f}, " f"max_possible_distance={self._max_possible_distance}, " f"cache_sizes={cache_stats}, " @@ -368,19 +368,25 @@ def __init__( self.previous_walking_density: float = 0 # Datacollector attributes - self.daily_walking_trips: int = 0 + self.walk_daily_trips: int = 0 self.work_trips: int = 0 self.basic_needs_trips: int = 0 self.leisure_trips: int = 0 # Initialize walking behavior - self.walking_behavior = WalkingBehaviorModel(model) + self.walking_behavior = DailyWalkingBehaviour(model) def _determine_working_status(self) -> bool: if self.age >= RETIREMENT_AGE: return False return self.random.random() < WORKING_PROBABILITY + def reset_daily_trips(self): + self.walk_daily_trips = 0 + self.basic_needs_trips = 0 + self.leisure_trips = 0 + self.work_trips = 0 + def get_friends(self) -> AgentSet: friend_count = self.random.randint(MIN_FRIENDS, MAX_FRIENDS) friend_set = AgentSet.select( @@ -496,7 +502,7 @@ def step(self): daily_walks = self.walking_behavior.simulate_daily_walks(self) # Update datacollector attributes - self.daily_walking_trips = len(daily_walks) + self.walk_daily_trips = len(daily_walks) self.work_trips = sum( [1 for activity, _ in daily_walks if activity == ActivityType.WORK] ) diff --git a/examples/city_walking_behaviour/city_walking_behaviour/model.py b/examples/city_walking_behaviour/city_walking_behaviour/model.py index 461bce04..19708c82 100644 --- a/examples/city_walking_behaviour/city_walking_behaviour/model.py +++ b/examples/city_walking_behaviour/city_walking_behaviour/model.py @@ -1,10 +1,10 @@ import math from mesa import Model +from mesa.agent import AgentSet from mesa.datacollection import DataCollector from mesa.experimental.cell_space import OrthogonalVonNeumannGrid from mesa.experimental.cell_space.property_layer import PropertyLayer -from mesa.experimental.devs import ABMSimulator from .agents import ( DOG_OWNER_PROBABILITY, @@ -40,11 +40,8 @@ def __init__( no_of_others: int = 475, scenario: str = "random_random", seed=None, - simulator=ABMSimulator(), ): super().__init__(seed=seed) - self.simulator = simulator - self.simulator.setup(self) # Initialize basic properties self.initialize_properties( @@ -64,211 +61,41 @@ def __init__( # Apply selected scenario self.apply_scenario(scenario) - # Model reporters: Fixed SES references for b_SES_4, c_SES_4, d_SES_4 - model_reporters = { - "avg_walk_ses1": lambda x: ( # average daily walking trips for SES=1 - sum( - agent.daily_walking_trips - for agent in x.agents_by_type[Human] - if agent.SES == 1 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_walk_ses2": lambda x: ( # average daily walking trips for SES=2 - sum( - agent.daily_walking_trips - for agent in x.agents_by_type[Human] - if agent.SES == 2 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_walk_ses3": lambda x: ( # average daily walking trips for SES=3 - sum( - agent.daily_walking_trips - for agent in x.agents_by_type[Human] - if agent.SES == 3 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_walk_ses4": lambda x: ( # average daily walking trips for SES=4 - sum( - agent.daily_walking_trips - for agent in x.agents_by_type[Human] - if agent.SES == 4 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_walk_ses5": lambda x: ( # average daily walking trips for SES=5 - sum( - agent.daily_walking_trips - for agent in x.agents_by_type[Human] - if agent.SES == 5 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_work_ses1": lambda x: ( # average work trips for SES=1 - sum( - agent.work_trips - for agent in x.agents_by_type[Human] - if agent.SES == 1 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_work_ses2": lambda x: ( # average work trips for SES=2 - sum( - agent.work_trips - for agent in x.agents_by_type[Human] - if agent.SES == 2 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_work_ses3": lambda x: ( # average work trips for SES=3 - sum( - agent.work_trips - for agent in x.agents_by_type[Human] - if agent.SES == 3 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_work_ses4": lambda x: ( # average work trips for SES=4 - sum( - agent.work_trips - for agent in x.agents_by_type[Human] - if agent.SES == 4 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_work_ses5": lambda x: ( # average work trips for SES=5 - sum( - agent.work_trips - for agent in x.agents_by_type[Human] - if agent.SES == 5 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_basic_ses1": lambda x: ( # average basic-needs trips for SES=1 - sum( - agent.basic_needs_trips - for agent in x.agents_by_type[Human] - if agent.SES == 1 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_basic_ses2": lambda x: ( # average basic-needs trips for SES=2 - sum( - agent.basic_needs_trips - for agent in x.agents_by_type[Human] - if agent.SES == 2 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_basic_ses3": lambda x: ( # average basic-needs trips for SES=3 - sum( - agent.basic_needs_trips - for agent in x.agents_by_type[Human] - if agent.SES == 3 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_basic_ses4": lambda x: ( # average basic-needs trips for SES=4 - sum( - agent.basic_needs_trips - for agent in x.agents_by_type[Human] - if agent.SES == 4 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_basic_ses5": lambda x: ( # average basic-needs trips for SES=5 - sum( - agent.basic_needs_trips - for agent in x.agents_by_type[Human] - if agent.SES == 5 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_leisure_ses1": lambda x: ( # average leisure trips for SES=1 - sum( - agent.leisure_trips - for agent in x.agents_by_type[Human] - if agent.SES == 1 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_leisure_ses2": lambda x: ( # average leisure trips for SES=2 - sum( - agent.leisure_trips - for agent in x.agents_by_type[Human] - if agent.SES == 2 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_leisure_ses3": lambda x: ( # average leisure trips for SES=3 - sum( - agent.leisure_trips - for agent in x.agents_by_type[Human] - if agent.SES == 3 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_leisure_ses4": lambda x: ( # average leisure trips for SES=4 - sum( - agent.leisure_trips - for agent in x.agents_by_type[Human] - if agent.SES == 4 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - "avg_leisure_ses5": lambda x: ( # average leisure trips for SES=5 - sum( - agent.leisure_trips - for agent in x.agents_by_type[Human] - if agent.SES == 5 - ) - / len(x.agents_by_type[Human]) - if x.agents_by_type[Human] - else 0 - ), - } + def create_model_reporters(): + """Create a dictionary of model reporters with minimal boilerplate""" + reporters = {} + metrics = [ + "walk_daily_trips", + "work_trips", + "basic_needs_trips", + "leisure_trips", + ] + + for metric in metrics: + for ses in range(1, 6): + name = ( + f"avg_{metric.split('_')[0]}_ses{ses}" # e.g. "avg_walk_ses1" + ) + + # Create the reporter function + reporters[name] = lambda x, m=metric, s=ses: ( + sum( + getattr(agent, m) + for agent in x.agents_by_type[Human] + if s == agent.SES + ) + / len(x.agents_by_type[Human]) + if x.agents_by_type[Human] + else 0 + ) + + return reporters + + # Generate the model_reporters dict + model_reporters = create_model_reporters() self.datacollector = DataCollector(model_reporters) + self.human_set: AgentSet = AgentSet([], random=self.random) # Add initial humans self.add_initial_humans() @@ -680,6 +507,9 @@ def find_available_cell(ses): male.family = female female.family = male + self.human_set.add(male) + self.human_set.add(female) + # Place singles (each gets their own household) for _ in range(self.no_of_singles): ses = self.generate_ses() @@ -689,7 +519,7 @@ def find_available_cell(ses): household = cell # Using cell as household identifier cell_occupancy[cell] += 1 # Increment occupancy - Human( + person = Human( self, gender=self.generate_gender(), family_size=1, @@ -701,18 +531,17 @@ def find_available_cell(ses): ) self.unique_id += 1 + self.human_set.add(person) + def step(self): """Advance the model by one step.""" - self.agents_by_type[Human].shuffle_do("step") - - # Reset daily walking trips - self.agents_by_type[Human].daily_walking_trips = 0 - self.agents_by_type[Human].work_trips = 0 - self.agents_by_type[Human].basic_needs_trips = 0 - self.agents_by_type[Human].leisure_trips = 0 + self.human_set.shuffle_do("step") self.datacollector.collect(self) + # Reset daily walking trips + self.human_set.do("reset_daily_trips") + def generate_gender(self) -> str: return "Female" if self.random.random() < FEMALE_PROBABILITY else "Male"