-
-
Notifications
You must be signed in to change notification settings - Fork 276
Add new GIS example: Solar Adoption model #381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7750cde
55ced8d
52ccf18
4addfc8
bcea338
5328d33
b5c8936
af16cea
6899f86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Solar Adoption Model | ||
|
|
||
| This model demonstrates a simulation of households adopting solar panels, driven by two key geographic factors: | ||
| 1. **Environmental (Raster Data):** The economic viability of solar is based on local solar radiation (simulated via a `RasterLayer`). | ||
| 2. **Social (Vector Data):** The "peer effect" where households are more likely to adopt solar panels if their neighbors—within a certain distance—have already adopted them. Households are represented as `GeoAgents`. | ||
|
|
||
| This example is specifically designed to showcase how `mesa-geo` integrates both **Continuous Space (Raster)** environments and **Discrete Space (Vector)** agents interacting with one another. | ||
|
|
||
| ## How to Run | ||
|
|
||
| To run the model, first install the dependencies: | ||
|
|
||
| ```bash | ||
| pip install -r requirements.txt | ||
| ``` | ||
|
|
||
| Then run the application: | ||
|
|
||
| ```bash | ||
| solara run app.py | ||
| ``` | ||
|
|
||
| Then visit the provided localhost address in your web browser. | ||
|
|
||
| ## Using the Interface | ||
|
|
||
| - **Number of Households:** Controls how many `Household` agents populate the environment. | ||
| - **Social Influence Weight:** Adjusts how strongly a household is influenced by neighbors who already have solar panels. | ||
| - **Economic Weight (Radiation):** Adjusts how strongly the underlying solar radiation `RasterLayer` influences adoption. | ||
|
|
||
| Watch how clusters of adoption naturally form in areas with high solar radiation and slowly spread outward through social influence! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import mesa_geo as mg | ||
|
|
||
|
|
||
| class Household(mg.GeoAgent): | ||
| """Solar panel adoption agent.""" | ||
|
|
||
| def __init__(self, model, geometry, crs, unique_id=None): | ||
| """Create a new Household agent.""" | ||
| super().__init__(model, geometry, crs) | ||
| self.unique_id = ( | ||
| unique_id | ||
| if unique_id is not None | ||
| else self.model.random.randint(1, 100000000) | ||
| ) | ||
| self.has_solar = False | ||
| self.solar_radiation = 0.0 | ||
|
|
||
| def step(self): | ||
| if self.has_solar: | ||
| return | ||
|
|
||
| neighbors = list( | ||
| self.model.space.get_neighbors_within_distance(self, distance=500) | ||
| ) | ||
|
|
||
| solar_neighbors = sum(1 for n in neighbors if getattr(n, "has_solar", False)) | ||
| total_neighbors = len(neighbors) | ||
| social_influence = ( | ||
| solar_neighbors / total_neighbors if total_neighbors > 0 else 0.0 | ||
| ) | ||
| economic_viability = self.solar_radiation | ||
|
|
||
| prob_adopt = (self.model.social_weight * social_influence) + ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personal opinion here: Since you re-evaluate at each step, these probabilities are a bit too high. Ends up with total adoption even with extremely low social + economic weights in a short number of steps. It may help visually to pad this a bit more. |
||
| self.model.economic_weight * economic_viability | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding two probabilities together doesn't make a ton of sense from a generative perspective IMO. Might be worth structuring it as a mixture model where each agent chooses to make an economic vs. social vs. noop decision, and then subsequently evaluating the social vs. economic probability. That way you're guaranteed to stay within 0 - 1 and you have a more proper probabilistic model to describe the "solar adoption" decision. |
||
| ) | ||
| prob_adopt += 0.01 | ||
|
|
||
| if self.model.random.random() < prob_adopt: | ||
| self.has_solar = True | ||
| self.model.total_adopted += 1 | ||
|
|
||
| def __repr__(self): | ||
| return f"Household {self.unique_id} (Solar: {self.has_solar})" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| from agents import Household | ||
| from mesa.visualization import Slider, SolaraViz, make_plot_component | ||
| from mesa_geo.visualization import make_geospace_component | ||
| from model import SolarAdoption | ||
|
|
||
|
|
||
| def solar_portrayal(agent): | ||
| """Portrayal function for households and solar radiation cells.""" | ||
| if isinstance(agent, Household): | ||
| portrayal = {} | ||
| if agent.has_solar: | ||
| portrayal["color"] = "gold" | ||
| portrayal["radius"] = 4 | ||
| else: | ||
| portrayal["color"] = "grey" | ||
| portrayal["radius"] = 3 | ||
| return portrayal | ||
| else: | ||
| # It's a Raster Cell | ||
| val = getattr(agent, "radiation", 0) | ||
| r = int(255 * val) | ||
| g = int(255 * val) | ||
| b = 0 | ||
| return (r, g, b, 128) | ||
|
|
||
|
|
||
| model_params = { | ||
| "num_houses": Slider("Number of Households", 100, 10, 500, 10), | ||
| "social_weight": Slider("Social Influence Weight", 0.3, 0.0, 1.0, 0.1), | ||
| "economic_weight": Slider("Economic Weight (Radiation)", 0.3, 0.0, 1.0, 0.1), | ||
| } | ||
|
|
||
|
|
||
| model = SolarAdoption() | ||
| page = SolaraViz( | ||
| model, | ||
| name="Solar Adoption", | ||
| model_params=model_params, | ||
| components=[ | ||
| make_geospace_component(solar_portrayal, zoom=9), | ||
| make_plot_component(["Adopted"]), | ||
| ], | ||
| ) | ||
|
|
||
| page # noqa |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import os | ||
| import random | ||
|
|
||
| import geopandas as gpd | ||
| import numpy as np | ||
| import rasterio | ||
| from rasterio.transform import from_origin | ||
| from shapely.geometry import Point | ||
|
|
||
| # Paths | ||
| script_dir = os.path.dirname(__file__) | ||
| raster_path = os.path.join(script_dir, "solar_radiation.tif") | ||
| vector_path = os.path.join(script_dir, "households.geojson") | ||
|
|
||
|
|
||
| def generate_raster(): | ||
| """Generate a procedural raster layer representing solar radiation.""" | ||
| width = 100 | ||
| height = 100 | ||
|
|
||
| # Create a procedural 2D numpy array (e.g. gradient + noise) for solar radiation | ||
| # Values from 0 (low radiation) to 1 (high radiation) | ||
| y, x = np.mgrid[0:height, 0:width] | ||
| gradient = (x + y) / (width + height) # Diagonal gradient | ||
| noise = np.random.normal(0, 0.1, (height, width)) | ||
| radiation_data = np.clip(gradient + noise, 0, 1) | ||
|
|
||
| # Reshape to (1, height, width) for Rasterio format | ||
| radiation_data = radiation_data.reshape(1, height, width).astype(np.float32) | ||
|
|
||
| # Create a Georeferenced Transform for the Raster (e.g., origin at 0,0, pixel size 1000m) | ||
| transform = from_origin(0, height * 1000, 1000, 1000) | ||
|
|
||
| with rasterio.open( | ||
| raster_path, | ||
| "w", | ||
| driver="GTiff", | ||
| height=height, | ||
| width=width, | ||
| count=1, | ||
| dtype=radiation_data.dtype, | ||
| crs="epsg:3857", | ||
| transform=transform, | ||
| ) as dataset: | ||
| dataset.write(radiation_data) | ||
| print(f"Generated {raster_path}") | ||
| return transform, width, height | ||
|
|
||
|
|
||
| def generate_vector(width, height): | ||
| bounds = [0, 0, width * 1000, height * 1000] | ||
| minx, miny, maxx, maxy = bounds | ||
|
|
||
| num_houses = 500 | ||
| points = [] | ||
| for _ in range(num_houses): | ||
| # Generate random points within the bounds | ||
| x = random.uniform(minx, maxx) | ||
| y = random.uniform(miny, maxy) | ||
| points.append(Point(x, y)) | ||
|
|
||
| # Create a GeoDataFrame from points | ||
| gdf = gpd.GeoDataFrame(geometry=points, crs="epsg:3857") | ||
| gdf.to_file(vector_path, driver="GeoJSON") | ||
| print(f"Generated {vector_path}") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| t, w, h = generate_raster() | ||
| generate_vector(w, h) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should preferably be under gis/solar_adoption/solar_adoption. Then the
app.pyshould import these withimport solar_adoption.agentsfor example.