Skip to content
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

space: Implement PropertyLayer and _PropertyGrid #1898

Merged
merged 37 commits into from
Jan 6, 2024

Conversation

EwoutH
Copy link
Member

@EwoutH EwoutH commented Dec 4, 2023

Summary

This PR introduces a system for managing cell properties within Mesa's grid classes. It adds a PropertyLayer class and extends the SingleGrid and MultiGrid classes to support cell properties, enhancing the capability of Mesa for more detailed environmental modeling in simulations.

Motive

The addition of cell properties brings a new dimension to agent-based models, allowing them to simulate environmental factors (e.g., terrain types, resources, pollution levels) more realistically.

From a technical perspective, cell properties reduce computational overhead by negating the need for numerous agent objects for static environmental features. Conceptually, they provide a clearer and more intuitive representation of the environment, streamlining model complexity and facilitating analysis.

This feature enhances Mesa's modeling capabilities by enabling dynamic interaction between agents and their environment. It allows for simulations where environmental conditions directly influence agent behavior, thus broadening the scope and depth of possible models.

Implementation

pako_eNrdVd9vmzAQ_lcsP9GNROR3gqpK0yrtZZUm5W1CQi6-EKtgI2Pasiz_-w5CgqGk27S38ZDA3Xd3n-8-2wcaKQ7Up1HC8vxesFizNJAEn9pCvmmVgTblV1aCJoeTq3pGW6OFjIlkKVhWIQ15Edzse7Y9iHhvLKMs0qwcS860ZiXhzLDW-TEHE0aQJM5npTQXkhkgmcqFEUq65JklB

PropertyLayer Class

The PropertyLayer class represents a layer of properties associated with the cells of a grid. It includes the following functionalities:

  • Initialization: The constructor initializes a 2D NumPy array to store property values for each cell, with a specified default value and data type.
  • Cell Value Management: Methods to set or modify the value of individual cells or batches of cells, either unconditionally or based on specified conditions.
  • Property Manipulation: Functions to apply operations (such as lambda functions or NumPy universal functions) to modify cell values, either individually or in a batch mode.
  • Property Querying: Capabilities to select cells based on certain conditions and perform aggregate operations across all cells (e.g., sum, average).

_PropertyGrid Class

The _PropertyGrid class extends the existing _Grid class, adding support for managing multiple PropertyLayer instances. Key features include:

  • Property Layer Management: Methods to add and remove property layers from the grid. Each property layer is identified by a unique name and is managed independently.
  • Interaction with Agents: Integration of property layers with agent actions, allowing agents to interact with and modify cell properties as part of their behavior.
  • Enhanced Grid Functionalities: Extension of existing grid functionalities to incorporate property-based conditions. For example, enabling agents to move to cells with specific properties or query neighboring cells based on property values.

Integration with Grid Classes

The existing grid classes, such as SingleGrid and MultiGrid, are derived from _PropertyGrid. This inheritance allows them to leverage the property layer functionalities seamlessly. These enhanced grid classes enable modelers to define and manipulate cell properties directly, enriching the modeling capabilities of Mesa.

Implementation Details

  • The implementation is compatible with existing Mesa models, ensuring no disruption to current users.
  • It supports lambda functions and NumPy ufuncs, providing flexibility in defining cell operations.

API examples

Here are some code examples of how these features could be utilized.

Initializing a Property Layer and Setting Cell Values

import numpy as np
from mesa.space import SingleGrid, PropertyLayer

# Initialize a SingleGrid with a PropertyLayer
grid = SingleGrid(10, 10, False)
property_layer = PropertyLayer("elevation", 10, 10, default_value=0)
grid.add_property_layer(property_layer)

# Set a specific cell's value
grid.properties["elevation"].set_cell((5, 5), 100)

# Set multiple cells' values based on a condition
condition = lambda x: x < 50  # Condition to check each cell's value
grid.properties["elevation"].set_cells(60, condition)

Modifying Cell Values Using Operations.
Note: If performance is important, NumPy Universal functions (ufunc) are often faster.

# Modify a single cell's value with a lambda function
grid.properties["elevation"].modify_cell((3, 3), lambda x: x + 20)

# Modify multiple cells' values with a NumPy ufunc
grid.properties["elevation"].modify_cells(np.multiply, 2)

Conditional Operations Based on Cell Properties

# Define a condition function
high_pollution = lambda x: x > 3.0

# Select cells that meet the condition
high_pollution_cells = grid.properties['pollution'].select_cells(condition=high_pollution)

# Reduce pollution in highly polluted cells using a lambda function
reduce_pollution = lambda x: x - 2.0 if x > 3.0 else x
grid.properties['pollution'].modify_cells(operation=reduce_pollution)

Aggregating Property Values
We can perform an aggregate operation, such as calculating the sum or mean of all values in the property layer.

# Calculate the total pollution level
total_pollution = grid.properties['pollution'].aggregate_property(np.sum)

# Calculate the average pollution level
average_pollution = grid.properties['pollution'].aggregate_property(np.mean)

Complex Cell Selection Based on Condition
We can select cells that meet a complex condition, such as finding cells within a specific range of values.

# Select cells where the value is between 5 and 15
selected_cells = layer.select_cells(lambda x: (x > 5) & (x < 15))

# Print the coordinates of the selected cells
print("Selected Cells:", selected_cells)

Model examples

Moving an Agent to a Cell with the Highest Value

In this example, we'll move an agent to the cell with the highest value in a specified property layer.

from mesa import Agent, Model
from mesa.space import SingleGrid, PropertyLayer
import numpy as np

class MyAgent(Agent):
    def move_to_highest(self):
        # Move the agent to the cell with the highest value in 'temperature' layer
        target_cells = self.model.grid.select_cells(extreme_values={"temperature": "highest"})
        self.model.grid.move_agent_to_one_of(self, target_cells)

class MyModel(Model):
    def __init__(self):
        self.grid = SingleGrid(10, 10, False)
        temperature_layer = PropertyLayer("temperature", 10, 10, default_value=20, dtype=int)
        self.grid.add_property_layer(temperature_layer)

        # Randomly increase temperature in some cells
        for _ in range(30):
            x, y = self.random.randint(0, 9), self.random.randint(0, 9)
            temperature_layer.set_cell((x, y), self.random.randint(20, 40))

        # Add an agent
        self.agent = MyAgent(0, self)
        self.grid.place_agent(self.agent, (5, 5))

# Create model and move agent
model = MyModel()
model.agent.move_to_highest()

print(f"Agent moved to: {model.agent.pos}")
Example 2: Moving an Agent Based on Multiple Property Conditions

Here, we move an agent to a random cell that satisfies multiple conditions based on different property layers.

from mesa import Agent, Model
from mesa.space import SingleGrid, PropertyLayer
import numpy as np

class MyAgent(Agent):
    def move_to_optimal_cell(self):
        conditions = {
            "temperature": lambda x: x < 30,
            "humidity": lambda x: x > 50
        }
        target_cells = self.model.grid.select_cells(conditions)
        if target_cells:
            self.model.grid.move_agent_to_one_of(self, target_cells)

class MyModel(Model):
    def __init__(self):
        self.grid = SingleGrid(10, 10, False)
        self.grid.add_property_layer(PropertyLayer("temperature", 10, 10, 20, dtype=int))
        self.grid.add_property_layer(PropertyLayer("humidity", 10, 10, 60.0, dtype=float))

        # Add an agent
        self.agent = MyAgent(0, self)
        self.grid.place_agent(self.agent, (5, 5))

# Create model and move agent
model = MyModel()
model.agent.move_to_optimal_cell()

print(f"Agent moved to: {model.agent.pos}")

Scenario: Forest Fire Simulation

In this simulation, we have a forest with varying levels of temperature, humidity, and elevation. Agents represent firefighters trying to contain a forest fire. The goal is for the firefighters to move towards areas with the highest temperature (indicative of fire) but also consider safety constraints such as avoiding extremely low humidity areas (high fire risk), inaccessible high elevation zones or extremely high temperature zones. They also have a maximum movement radius of 3 (using the neighbor mask) and won't move to a cell where already a firefighter is (using the empty mask).

import numpy as np

from mesa import Agent, Model
from mesa.space import PropertyLayer, SingleGrid
from mesa.time import RandomActivation


class FirefighterAgent(Agent):
    def step(self):
        current_location = self.pos

        # Move to the hottest eligible cell
        self.move_to_hottest_eligible_cell()

        current_temperature = self.model.grid.properties["temperature"].data[self.pos]

        # Decrease the temperature (in Kelvin) by 20% at the agent's current cell
        self.reduce_temperature()

        print(
            f"Firefighter {self.unique_id} moved from {current_location} to {self.pos}, and decreased the temperature there from {current_temperature:.2f} to {self.model.grid.properties['temperature'].data[self.pos]:.2f}"
        )

    def move_to_hottest_eligible_cell(self):
        # Define additional conditions
        conditions = {
            "humidity": lambda x: x > 30,  # Humidity in %
            "elevation": lambda x: x < 75,  # Elevation in meters
            "temperature": lambda x: x < 800,  # Temperature in Kelvin
        }

        # Create masks for the neighborhood
        neighborhood_mask = self.model.grid.get_neighborhood_mask(
            self.pos, moore=True, include_center=False, radius=3
        )

        # Select target cells based on conditions, extreme values, and masks
        target_cells = self.model.grid.select_cells(
            conditions=conditions,
            extreme_values={"temperature": "highest"},
            masks=neighborhood_mask,
            only_empty=True,
        )
        # Move the agent to one of the selected cells
        self.model.grid.move_agent_to_one_of(self, target_cells)

    def reduce_temperature(self):
        self.model.grid.properties["temperature"].modify_cell(
            self.pos, np.multiply, 0.8
        )


class ForestFireModel(Model):
    def __init__(self, N, width, height):
        super().__init__()
        self.num_agents = N
        self.grid = SingleGrid(width, height, False)
        self.schedule = RandomActivation(self)

        # Create property layers
        self.grid.add_property_layer(
            PropertyLayer("temperature", width, height, 300.0, dtype=float)
        )
        self.grid.add_property_layer(
            PropertyLayer("humidity", width, height, 60, dtype=int)
        )
        self.grid.add_property_layer(
            PropertyLayer("elevation", width, height, 0, dtype=int)
        )

        # Initialize property layers with random values
        self._init_property_layers()

        # Add agents
        for i in range(self.num_agents):
            agent = FirefighterAgent(i, self)
            self.grid.move_to_empty(agent)
            self.schedule.add(agent)

    def _init_property_layers(self):
        # Access the NumPy arrays of each property layer
        temperature_data = self.grid.properties["temperature"].data
        humidity_data = self.grid.properties["humidity"].data
        elevation_data = self.grid.properties["elevation"].data

        # Assign random values to the entire arrays at once
        temperature_data[:] = np.random.randint(270, 370, temperature_data.shape)
        humidity_data[:] = np.random.randint(20, 80, humidity_data.shape)
        elevation_data[:] = np.random.randint(0, 100, elevation_data.shape)

    def step(self):
        # Increase the temperature of all cells by 5
        print(
            f"Starting step {self.schedule.steps}. Average temperature: {self.grid.properties['temperature'].aggregate_property(np.mean):3f}"
        )
        self.grid.properties["temperature"].modify_cells(np.add, 5)

        self.schedule.step()


# Create and run the model
forest_fire_model = ForestFireModel(25, 100, 100)
for _i in range(100):  # Run for 10 steps
    forest_fire_model.step()

Key Features of This Example

  1. Multiple Property Layers: Temperature, humidity, and elevation layers model different aspects of the environment.
  2. Agent Behavior: Firefighter agents move towards high-temperature zones while avoiding low humidity and high elevation areas, simulating strategic movement in a forest fire scenario.
  3. Dynamic Environment: The model demonstrates how agents can interact with and respond to a multi-layered, dynamically changing environment.

Copy link

codecov bot commented Dec 4, 2023

Codecov Report

Attention: 26 lines in your changes are missing coverage. Please review.

Comparison is base (03d0c81) 79.62% compared to head (363fb0b) 79.87%.
Report is 12 commits behind head on main.

Files Patch % Lines
mesa/space.py 79.36% 17 Missing and 9 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1898      +/-   ##
==========================================
+ Coverage   79.62%   79.87%   +0.24%     
==========================================
  Files          15       15              
  Lines        1124     1267     +143     
  Branches      244      277      +33     
==========================================
+ Hits          895     1012     +117     
- Misses        198      216      +18     
- Partials       31       39       +8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 4, 2023

This PR is now feature complete! Please take a proper look at the code, it's ready for review.

Next up:

  • Tests
  • Docstring
  • Updating PR message
  • Adding more examples

@EwoutH EwoutH marked this pull request as ready for review December 4, 2023 21:20
Copy link
Contributor

@Corvince Corvince left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super excited for this! Obviously still some rough edges, but I think this has very high potential.

I left some comments. One thing that got me thinking. You often define methods that potentially only affect a neighborhood. For this you always have the optional arguments only_neighborhoods, moore, include_center and radius. This somewhat predetermines what a neighborhood can be. But maybe we can define "neighborhood" just as a list of coordinates. Then we can define neighborhoods also based on conditions and only pass in the neighborhoods to these functions. Maybe that would be more general?

I also think some method names are not optimal. But I think to fully judge that we would need some toy model where we can "read" how models are written.

Anyway, again, great work!

mesa/space.py Outdated
def select_cells_multi_properties(
self,
conditions: dict,
only_neighborhood: bool = False,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this could be simplified to a an optional neighborhood argument that precalculated the neighborhood? And _get_neighborhood_mask only masks a neighborhood. That way we don't need all the extra arguments for the neighborhood and this could also be applied for other spaces, where neighborhoods can be defined differently

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, will think about how to make this modular.

If you have any suggestions, let me know!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented! It now takes a mask with a utility method get_neighborhood_mask()

mesa/space.py Outdated Show resolved Hide resolved
mesa/space.py Outdated
raise ValueError(
"Condition must be a NumPy array with the same shape as the grid."
)
np.copyto(self.data, value, where=condition) # Conditional in-place update
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cant we update the values in-place, why the copy?

mesa/space.py Outdated
else:
# Ensure condition is a boolean array of the same shape as self.data
if (
not isinstance(condition, np.ndarray)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, is condition a callable that returns a boolean array or already a boolean array?

Copy link
Member Author

@EwoutH EwoutH Dec 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent catch, I went a little over board there with the optimization. I think I fixed it in 5f34480.

Could you confirm that now makes sense?

mesa/space.py Outdated
return operation(self.data)
else:
# NumPy ufunc case
return operation(self.data)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both branches do the same, so I think we dont need the branching?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, fixed in 7b9310c.

mesa/space.py Outdated
def move_agent_to_random_cell(
self,
agent: Agent,
conditions: dict,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool idea to randomly move, but only to a specified neighborhood.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I think generalizing that is indeed a good idea, I replied below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, you can now insert any mask. Utility methods get_neighborhood_mask() and get_empty_mask are provided.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 4, 2023

All docs and tests are added. That should reviewing a bit easier.

  • I'm quite certain I went for the right approach with PropertyLayer. I like the code base and the interface, and think it's quite performant and inuative
  • I also like the basic construct of adding one or more PropertyLayers to a grid. I think using a private _PropertyGrid of which SingleGrid and MultiGrid inherit is the right approach.
  • I'm not fully content with the build in methods of _PropertyGrid, especially the last three:
    • select_cells_multi_properties
    • move_agent_to_random_cell
    • move_agent_to_extreme_value_cell
      They work well but everything about them is just a little complicated, and maybe too specific. Any feedback on those will be extra appreciated.

While this PR is fully implemented and quite specific, feel also free to talk about the big picture stuff. I'm proposing a major conceptual change to the Mesa space stack, and thus discussion on all levels is appreciated.


@rht Ruff doesn't allow me to specify a condition as a lambda function:

condition = lambda x: x == 0
tests/test_space.py:557:9: E731 Do not assign a `lambda` expression, use a `def`
Found 7 errors.
No fixes available (7 hidden fixes can be enabled with the `--unsafe-fixes` option).

Direct insertion is okay, so without using the condition variable in between.

Being able to use lambda function to update the property layer is fundamental to this PR, and I like the current explicit condition = lambda approach. How do you think we should approach this.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 4, 2023

One thing that got me thinking. You often define methods that potentially only affect a neighborhood. For this you always have the optional arguments only_neighborhoods, moore, include_center and radius. This somewhat predetermines what a neighborhood can be. But maybe we can define "neighborhood" just as a list of coordinates. Then we can define neighborhoods also based on conditions and only pass in the neighborhoods to these functions. Maybe that would be more general?

Yes, I like this. Just pass a sub selection of cells, which can be a neighbourhood but also can be something else. That would be extremely flexible, you can define rooms, areas, basically anything you like.

I also think some method names are not optimal

Struggling with this, really open for suggestions!

@wang-boyu
Copy link
Member

Great work!

I'm wondering what type of indices is used for cells. In grid space the coordinate system is in (x, y) with origin at the lower left corner, which is typical Cartesian coordinates (if I remember correctly, but I might be wrong about this). However in matrices (or numpy arrays) we often use the (row, column) format, with an origin at the upper left corner. In mesa-geo I ended up using both: https://github.com/projectmesa/mesa-geo/blob/95cdcebb7490b0e832308ce8bb6161915c5de875/mesa_geo/raster_layers.py#L177C13-L180

Another question is that it seems to be possible to have property layers with different dimensions than that of the grid space. If this is the case, how do we align them? This also links to the coordinate system mentioned above. In mesa-geo, raster layers can have different dimensions and resolutions, and everything was taken care of through a crs setting in GeoSpace. But I think this is gis specific and may not apply here.

As an example, if you have a 10x10 grid, and a 20x20 property layer, where would you put it on the grid?

If, alternatively, you would prefer to make sure that property layers always have the same dimension as the grid, perhaps we could simplify the API to something like this?

# Initialize a SingleGrid
grid = SingleGrid(width=10, height=10, torus=False)

# Create a PropertyLayer for a specific property (e.g., pollution level)
# and add the property layer to the grid
grid.add_property_layer(name='pollution', default_value=0.0)

# or
grid.add_property_layer(name='another_property', values=some_numpy_array_of_the_same_shape)

@EwoutH
Copy link
Member Author

EwoutH commented Dec 4, 2023

Based on Corvince’s insights:

  • Instead of neighborhood arguments, take a mask. It can be an empty cell mask, neighborhood mask, or any mask you like.
    • Add utility method to create mask from list of cells
    • Add utility methods to create neighborhood and empty cell masks
  • Maybe split move_agent_to_extreme_value_cell into a select target and move to target method. That way you can mix and match them.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 4, 2023

Thanks for your insights Wang!

I'm wondering what type of indices is used for cells. In grid space the coordinate system is in (x, y) with origin at the lower left corner, which is typical Cartesian coordinates (if I remember correctly, but I might be wrong about this). However in matrices (or numpy arrays) we often use the (row, column) format, with an origin at the upper left corner.

Not even thought of this. 😅

Another question is that it seems to be possible to have property layers with different dimensions than that of the grid space. If this is the case, how do we align them?

Was also thinking about this for ContinuousSpace. I would say out of scope for this PR, but in general, it would be nice to support this (especially because NetLogo is ContinuousSpace + discrete patches).

If, alternatively, you would prefer to make sure that property layers always have the same dimension as the grid, perhaps we could simplify the API to something like this?

Personally I wouldn’t go this way, because now property layers are completely independent. It would be nice to keep them that way.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 6, 2023

Quite productive morning so far!

  • In d731e2f I removed the neighborhood stuff from the PropertyGrid methods. They now take a mask argument
  • In 478506b I allowed returning a mask or a list, for added flexibility
  • In 5857c4f I split move_agent_to_extreme_value_cell into two methods: A select and a move method
  • In c2f1b2d I wrote a utility function to create an empty mask
  • In ca72bee I renamed two methods. I hope they're clearer now.
  • In the other commits, I added docstring, updated the tests and fixed the remaining issues.

@wang-boyu I added a test in 68992a0 to check the coordinate systems between agents and layers. It looks like they match. Can you check if I tested correctly and haven't forgotten an edge case?


@rth I would really like to know what you think about Ruff not allowing assigned lambda functions.


Code, test and docs wise, this PR is done. I will now work on some examples to show functionality. I would invite everyone to do that with me and play around with it a bit. This branch can be installed with:

pip install -U -e git+https://github.com/EwoutH/mesa@property_layer#egg=mesa

@EwoutH
Copy link
Member Author

EwoutH commented Dec 6, 2023

I added 3 examples to the main PR message. I think is shows (almost) all functionality. Really curious what everybody thinks!

@wang-boyu
Copy link
Member

@wang-boyu I added a test in 68992a0 to check the coordinate systems between agents and layers. It looks like they match. Can you check if I tested correctly and haven't forgotten an edge case?

Thanks for adding a test case for it. But it's not really what I'm trying to say.

I made a small visualization for your forest fire model:

code
import numpy as np
import solara
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from mesa.experimental import JupyterViz


def space_drawer(model, agent_portrayal):
    agents_portrayal = {
        "firefighter": {"x": [], "y": [], "c": "tab:red", "marker": "o", "s": 10},
    }
    for agent in model.schedule.agents:
        if isinstance(agent, FirefighterAgent):
            agents_portrayal["firefighter"]["x"].append(agent.pos[0])
            agents_portrayal["firefighter"]["y"].append(agent.pos[1])

    fig, ax = plt.subplots()
    im = ax.imshow(model.grid.properties["temperature"].data, cmap="spring")
    # im = ax.imshow(model.grid.properties["temperature"].data, cmap="spring", origin="lower")
    fig.colorbar(im, orientation="vertical")

    ax.scatter(**agents_portrayal["firefighter"])
    ax.set_axis_off()
    solara.FigureMatplotlib(fig)


model_params = {
    "N": 1,
    "width": 4,
    "height": 4,
}

page = JupyterViz(
    ForestFireModel,
    model_params,
    measures=[],
    name="Forest Fire",
    space_drawer=space_drawer,
    play_interval=1500,
)
page  # noqa

For simplicity I also changed your _init_property_layers() function, from

temperature_data[:] = np.random.randint(270, 370, temperature_data.shape)

to

temperature_data[0, 0] = 500

so that the cell at position (0, 0) has a temperature of 500, with other cells having the default temperature of 300. Now the temperature property becomes

model = ForestFireModel(1, 4, 4)
model.grid.properties["temperature"].data

array([[500., 300., 300., 300.],
       [300., 300., 300., 300.],
       [300., 300., 300., 300.],
       [300., 300., 300., 300.]])

If we plot it, it looks like

Screenshot 2023-12-06 at 10 53 01

which isn't what we want, because (0, 0) is supposed to be at the lower left corner. This is essentially what I was trying to explain earlier.

I just realized that the same problem has been dealt with in the sugarscape demo: https://github.com/projectmesa/mesa-examples/blob/7275b172aad8905584dcffc131d9bbd96992abb7/examples/sugarscape_g1mt/app.py#L36-L38. In the demo code above I have a line commented out, that specifies origin="lower" when plotting the temperature layer. In that case, the visualization will look correct.

However I should mention that this only makes the model looks correct. The underlying numpy array still has its upper left corner with the 500 temperature. If we go for this approach, we probably need to make sure that

  1. we and the users will use position in (x, y) format for consistency
  2. all visualizations of property layers should have origin="lower"
  3. the underlying numpy array is not the same as what it looks like in visualizations

Sorry about all these. I hope this helps clarifying my concern.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 6, 2023

@wang-boyu Thanks for your extensive reply. I now understand:

  • In the NumPy grid 0,0 starts in the top left corner
  • It should be in the bottom left corner

What I don't understand yet:

  • Why do all the tests pass when mixing up agent positions on the Mesa Grid with positions on a NumPy property layer. Shouldn't that fail somehow?
  • How breaking is this?
  • On what levels do we need to fix this? Only visualisation, or more?

Edit: I start to get it better. At 0,0, the agent thinks it's in the lower left, but when asking patch 0,0, it's in the upper right.

Oh man this is a head breaker.

With you GIS experience, any suggestion how to move forward?

@EwoutH
Copy link
Member Author

EwoutH commented Dec 6, 2023

I already hate this, but adding a translation method that flips the y coordinate of every position like this:

    def _translate_coords(self, position):
        """
        Translates Mesa coordinates to NumPy array coordinates.
        """
        x, y = position
        return self.height - 1 - y, x

And then using that everywhere where a position is inputed:

    def set_cell(self, position, value):
        """
        Update a single cell's value in-place.
        """
        self.data[self._translate_coords(position)] = value

Would probably fix it right.

Except if users start manipulating the class directly, without using the methods...

I already hate this.

@Corvince
Copy link
Contributor

Corvince commented Dec 6, 2023

Your hate is very understandable. What we could potentially do is use a numpy array with dtype=object for the agents as well. Last time I checked it was marginally slower, but would allow easier consistency.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 6, 2023

What we could potentially do is use a numpy array with dtype=object for the agents as well.

And then just flip all the visualisations?

@wang-boyu
Copy link
Member

lol welcome to the world of gis... this is the exact problem I ran into when working on the urban_growth example model. I kept wondering why the results were always flipped around.

Regarding this PR, I think this really is just a design decision. How would you prefer the users to use the feature? Possibly:

  1. We treat the numpy array as an implementation detail that is hidden from the users. Indexing is through (x, y) coordinate everywhere. We only need to make sure that, the right way to interpret the array (e.g., when visualizing it) is to set origin="lower". I believe this is what we did in the sugarscape example.
  2. We make things right everywhere. When manipulating the array, we do the transformation from (x, y) to (row, col) through a utility function _translate_coords () like you mentioned above. The users will have to do the same thing when working with the array directly.

Regarding the first option, it is also possible to make the numpy array as a Python property without a setter. And when getting the array, we can do the flipping before returning it. But this may be unexpected to those users who are familiar with matrix indexing. We just need to be consistent that it is not (row, col) indexing, but instead (x, y) coordinate.

There might be other better options that I missed.

@EwoutH
Copy link
Member Author

EwoutH commented Dec 7, 2023

We treat the numpy array as an implementation detail that is hidden from the users. Indexing is through (x, y) coordinate everywhere. We only need to make sure that, the right way to interpret the array (e.g., when visualizing it) is to set origin="lower". I believe this is what we did in the sugarscape example.

I thought about it for a bit, and I think this is the right approach. It's basically just a convention for visualisation. In future efforts, we might want to create a visualisation function that included the up-down flip.

On a side note: We have to be really careful if we ever want to add something with directions. Because if we do, up is down and down is up.

Regarding the first option, it is also possible to make the numpy array as a Python property without a setter.

I like this. Let me work on a implementation. We basically define it like this, right?

    @property
    def data(self):
        return self._data

As a side note, I really would like feedback on the API and the variable names. This is the stuff that's difficult to change after the first release. So are there any names of methods that veel unintuitive, or could be improved?

@EwoutH
Copy link
Member Author

EwoutH commented Dec 7, 2023

To be honest, I don't know if we want data as a strict property. Doing some initializations as np.arange(100).reshape(10, 10), np.full((10, 10), 2) is pretty powerful, and (as far as I understand) making data a property without a setter would disable that.

Or should we include a setter and do some checks there?

@wang-boyu
Copy link
Member

I thought about it for a bit, and I think this is the right approach. It's basically just a convention for visualisation. In future efforts, we might want to create a visualisation function that included the up-down flip.

Yes I prefer this approach too, which is also consistent with the sugarscape example model . To recap, we assume indexing such as data[1, 2] means x=1 and y=2. This is different from the usual way of (row, col) indexing, but it is what we specify in Mesa.

A side effect of this approach is the internal representation using the numpy array will be upside down. To mitigate this problem, there are again a few options which are essentially design decisions:

  1. Do nothing with the setter and getter. When plotting the layer, use origin="lower". This is what we did in the sugarscape model.
  2. Either transform the coordinate in the setter, or flip the numpy array in the getter. In this way the array representation will be correct, and we don't need to do origin="lower" in plots. An example of changing the getter would be (while the setter does nothing but returning the _data)
    @property
    def data(self):
        return np.flipud(self._data)  # flip the layer; not very sure whether it works as expected
    So is the user does data[0, 0] = 500, then the cell at the lower left corner gets updated. Might come as a surprise to those who are familiar with matrix indexing.

Regarding the APIs I don't have any preferences. My only questions is whether you would like to call it cell or patch or anything else.

mesa/space.py Outdated
)
return # Agent stays in the current position

new_pos = tuple(agent.random.choice(target_cells))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be surprising that a cell is chosen randomly

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would your alternative be, if we have multiple cells with the same highest or lowest value?

@Corvince
Copy link
Contributor

Corvince commented Dec 9, 2023

Regarding the APIs I don't have any preferences. My only questions is whether you would like to call it cell or patch or anything else.

This is also something I noticed and I wonder if we should drop a concrete name altogether. I would be in favor of using more generic names for most of the functions. Anything with cell or even patch in the name implies a grid. If we later extend to 3 dimensional spaces or for networks the terminology doesn't fit anymore.

I would prefer to exchange cell with value in most cases. For example instead of set_cell we could have set_value, which fits for all kind of property layers.

And move_to_extreme_value_cell could just drop the cell, which would also make it a bit shorter.

In cases where value doesn't fit maybe we can use pos or position, which is still generic.

Otherwise I am quite happy with this PR. Really excellent intro post, great examples.

Currently on mobile so I can't to a proper code review, but I agree that having a proper API is currently more important and implementation details can also be improved later (if there are any improvements)

mesa/space.py Outdated Show resolved Hide resolved
@EwoutH
Copy link
Member Author

EwoutH commented Jan 6, 2024

I notice I’m mentally overloading a bit on this PR. My brain is running out of RAM to track all the discussions and comments. This PR now tracks 85 comments and over a month’s worth of discussion.

I would highly recommend merging this experimental feature as-is. Everything works, is tested, and is documented. Making iterative, atomic modifications on each of the potential improvement.

This feature is exactly for which #1909 was meant. We can break everything about it an hundred times and scrap it fully if we want to.

Please use Squash and merge while merging. I don’t want 30 commits in the commit history. The PR serves as historical reference and grounding.

@rht
Copy link
Contributor

rht commented Jan 6, 2024

@EwoutH I can take over applying the rest of the change requests. Should be done in less than an hour and then I will squash and merge. Well done!

@rht
Copy link
Contributor

rht commented Jan 6, 2024

@rht rht merged commit 0bd4429 into projectmesa:main Jan 6, 2024
11 of 13 checks passed
@EwoutH
Copy link
Member Author

EwoutH commented Jan 6, 2024

Thanks for taking care of the comments and merging!

Very glad it's in!

@dylan-munson
Copy link

dylan-munson commented May 3, 2024

Apologies if this is the incorrect place to ask this question, I am relatively new to this community and Github in general, but one issue I have encountered using this awesome feature is how to use it with the datacollector method.

I'm trying to implement the sugar/spice model with pollution in Mesa (modifying the sugarscape_g1mt model from mesa-examples), and currently have the following in my datacollector definition:

self.datacollector = mesa.DataCollector( model_reporters= {"Pollution": lambda m: m.grid.properties["pollution"].data} )

Everything (as in the rest of the data collection, excluded from the code for space reasons) seems to work as intended, as in the base model, except for the pollution data; it seems that for all steps, only the final pollution property layer is being stored (i.e. in the final results dataframe, the "Pollution" column is identical across model steps). Perhaps this is just me misunderstanding how the data collector works, but I wanted to see if this was potentially just not something that the PropertyLayer class is intended to handle. I imagine there should be some way to do it (for this model, we of course expect the distribution over space of pollution to change with each step), but I'm struggling to figure it out. Any help is greatly appreciated!

@EwoutH
Copy link
Member Author

EwoutH commented May 3, 2024

Thanks for reaching out! Can you share a minimal reproducible example?

@dylan-munson
Copy link

Yes, apologies for not doing so initially. Below is an extremely simplified version of what I am trying to do:

import mesa

class testAgent(mesa.Agent):
    def __init__(self, unique_id, model, pos):
        super().__init__(unique_id, model)
        self.pos = pos
    def pollute(self):
        self.model.grid.properties["pollution"].modify_cell(self.pos, lambda p: p+1)

class testModel(mesa.Model):
    def __init__(self, width = 20, height = 20, initial_population=200):
        super().__init__()
        self.width = width
        self.height = height
        self.initial_population = initial_population
        
        # initiate activation schedule
        self.schedule = mesa.time.RandomActivationByType(self) #activate different type of agents in different order each time
        # initiate mesa grid class
        self.grid = mesa.space.MultiGrid(self.width, self.height, torus=False)
        #add pollution property layer
        pollution_layer = mesa.space.PropertyLayer(name = "pollution", width = self.width, height = self.height, default_value = 0)
        self.grid.add_property_layer(pollution_layer)
        
        self.datacollector = mesa.DataCollector(model_reporters = {"Pollution": lambda m: m.grid.properties["pollution"].data})
        
        agent_id = 0
        for i in range(self.initial_population):
            # get agent position
            x = self.random.randrange(self.width)
            y = self.random.randrange(self.height)
            agent = testAgent(agent_id, self, (x, y))
            # place agent
            self.grid.place_agent(agent, (x, y))
            self.schedule.add(agent)
            agent_id += 1
    
    def randomizeAgents(self):
        agents_shuffle = list(self.schedule.agents_by_type[testAgent].values())
        self.random.shuffle(agents_shuffle)

        return agents_shuffle
    
    def step(self):
        agents_shuffle = self.randomizeAgents()
        for agent in agents_shuffle:
            agent.pollute()
        
        self.datacollector.collect(self)
       
    def run_model(self, step_count=20):
        for i in range(step_count):
            self.step()
            
model = testModel()
model.run_model()
model_results = model.datacollector.get_model_vars_dataframe()

If you check the model_results object after running, you will see that all of the arrays in the Pollution column are exactly the same--specifically, they seem to be the final step levels of pollution in each grid cell. The pollution at step 0, at least, should be 0 in every entry, which is not the case.
So I am wondering if the issue is with the way I am calling the pollution property layer in DataCollector (perhaps with the lambda function I am using? or the way the step function has been defined?), or if use with the DataCollector is just not part of the functionality for PropertyLayer. Thanks.

@EwoutH
Copy link
Member Author

EwoutH commented May 5, 2024

Thanks for the minimal example. I can reproduce this bug, and it's indeed unintended behavior. Could you open a new issue for this?

I will investigate a bit further in the meantime.

@dylan-munson
Copy link

Thanks for looking into this, I've opened an issue and will check back for updates/to see if anything else is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
experimental Release notes label feature Release notes label
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants