Skip to content

Support multiple spaces (using observable positions)#3043

Closed
EwoutH wants to merge 3 commits intomesa:mainfrom
EwoutH:observable_positions
Closed

Support multiple spaces (using observable positions)#3043
EwoutH wants to merge 3 commits intomesa:mainfrom
EwoutH:observable_positions

Conversation

@EwoutH
Copy link
Copy Markdown
Member

@EwoutH EwoutH commented Dec 30, 2025

Proof of concept

Summary

This PR implements support for agents existing in multiple aligned spatial representations simultaneously (e.g., continuous space + grid + hex grid), with automatic synchronization across all spaces through a unified position model.

Motive

Current Mesa models are limited to agents existing in a single space. However, many real-world models benefit from multiple spatial views:

  • Environmental models need continuous positioning with discrete resource grids
  • Urban models combine continuous movement with administrative districts (Voronoi)
  • Ecological models layer continuous habitats over discrete terrain patches

The key challenge is maintaining consistency: when an agent moves, all spatial representations must update correctly and efficiently.

Implementation

Core Architecture: Position as Single Source of Truth
Agents now have a unified agent.position (numpy array) that serves as the authoritative location. All spaces reference this shared position rather than maintaining separate copies:

class Agent:
    @property
    def position(self) -> np.ndarray | None:
        """Unified position across all spaces."""
        return self._position
    
    @position.setter
    def position(self, value: np.ndarray | None) -> None:
        """Set position and notify all discrete spaces."""
        self._position = value
        # Automatically notify registered discrete spaces
        for space in self._discrete_spaces:
            space._on_agent_position_changed(self, old_position, value)

Observable Position Pattern
Discrete spaces (grids, hex grids) subscribe to position updates via an observable pattern. When agent.position changes, the setter automatically notifies registered spaces to update cell membership:

  • Discrete spaces: Subscribe for immediate updates (needed for cell.agents to be correct)
  • Continuous spaces: Use the position directly (no subscription needed)
  • Performance: O(d) where d is number of discrete spaces (typically 1-2)

Space API Changes
Spaces now provide:

  • space.add(agent) - Register agent in space
  • space.cell(agent) - Read-only query of current cell
  • space.move(agent, target_cell, align="center") - Move agent, updating position
  • space.position_to_cell(position) - Convert position to cell (O(1) for grids, O(log n) for Voronoi)
  • space.cell_to_position(cell) - Get canonical position for cell (typically center)

Efficient Position-to-Cell Conversion
Each space type implements efficient coordinate conversion:

  • Grid: O(1) via floor(x), floor(y)
  • HexGrid: O(1) via axial coordinate system (standard hex math)
  • VoronoiGrid: O(log n) via KD-tree lookup (future work)

Usage Examples

Movement Options
Spaces provide flexible positioning within cells:

# Move to cell center (default)
self.model.grid.move(self, target_cell, align="center")

# Random position within cell
self.model.grid.move(self, target_cell, align="random")

# Direct position update (all spaces sync)
self.position += velocity

For a full example model, see the implemented example model in this PR.

Additional Notes

Backwards Compatibility: agent.pos is preserved for compatibility. New code should use agent.position.

Performance: Zero overhead for single-space models (current behavior). Multi-space models have minimal overhead:

  • Position updates: O(d) where d = discrete spaces (typically 1-2)
  • Cell queries: O(1) cached lookup
  • Coordinate conversion: O(1) for grids, O(log n) for Voronoi

Future Work:

  • VoronoiGrid position conversion (requires KD-tree implementation)
  • Network spaces (spatial networks vs abstract graphs)
  • Additional alignment modes (closest point, edge, etc.)

Related Discussions:

Feedback appreciated!

Not validated yet, just as an illustration
@EwoutH EwoutH requested a review from quaquel December 30, 2025 14:49
@EwoutH EwoutH added the feature Release notes label label Dec 30, 2025
@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Dec 30, 2025

Currently we save the membership of agents to cell spaces in both in the Agent self._discrete_spaces: list = [] and in the discrete space self._agents: set[Agent] = set(). The third possible place is to store it in the model (a dict of sets?). The questions is which of those places are most suitable.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Dec 30, 2025

Some random thoughts.

  1. I really like the current agent-centric API for positions and movement. This was a key design choice in the new continuous space and the discrete spaces. Is this still possible in this design? I really dislike the old API where you do self.model.space.move, yet I see it reappearing here via self.model.grid.move(self, target, align="center"). I really want to keep being able to do self.position = new position (even without the casting to numpy array that is done in your example).
  2. I don't see a clear signal/observer design pattern yet. What you currently do under #notify discrete spaces is just calling a protected method.
  3. The current API, where an agent has to register itself in all spaces, seems clunky to me. Can't we do something much more like the property layers here? An agent only needs to know its position in the "master" space, and automagically gets attributes that define its position in the "derived" spaces. So, if you have, say, an x, y coordinate, you can just do e.g., self.district to get the district in which the agent resides.
  4. It seems you have the multiple spaces design baked into all spaces by default, rather than it being an opt-in. What is the overhead, if anything, of this, and how can you control which space is the "master" space? I still like my suggestion to mimic matplotlib's sharex/sharey design:
# Create aligned spaces (all use the same coordinate system)
self.continuous = ContinuousSpace([[0, 10], [0, 10]], random=self.random)
self.grid = OrthogonalMooreGrid([10, 10], random=self.random, shared_coordinates=self.continuous)
self.hex_grid = HexGrid([10, 10], random=self.random, shared_coordinates=self.continuous)

Clearly, the keyword name shared_coordinates needs improvement. But at least this is very explicit. This might even help address my previous point, as the relationship between spaces is now explicitly declared at the level of the spaces, where it belongs, rather than being done implicitly via the agents.

  1. There are various more detailed comments on methods, both their naming and whether they are needed, but that can wait until the above issues are fleshed out in more detail.
  2. Very nitpicky, but the statement "Current Mesa models are limited to agents existing in a single space" is simply not true. I have, with Mesa 3, built models where agents exist in multiple spaces, and I have built models where a model has multiple spaces, while an agent can only be present in one. Both, however, require an understanding of the implementation details under the hood and are thus not easily accomplished by the typical user.
  3. How does this design compare to mesa geo?

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Dec 30, 2025

Currently we save the membership of agents to cell spaces in both in the Agent self._discrete_spaces: list = [] and in the discrete space self._agents: set[Agent] = set(). The third possible place is to store it in the model (a dict of sets?). The questions is which of those places are most suitable.

As indicated under 3 and 4, I am not sure about this design in the first place. Is it a property of the agents to be present in multiple spaces, or is it a relationship between spaces within which agents reside?

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Dec 30, 2025

Thanks for your insights. There are certainly trade offs in this design. I will get back to some of your questions and thoughts later.

Maybe we need to take some time to meet up and align goals, design directions and possible implementations. I feel like al the ideas are there, it just needs to come together.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Dec 30, 2025

A few initial responses:

  1. This implementation does allow self.position = new_position (without casting) - the setter handles numpy conversion automatically. The self.model.grid.move() API was added to provide space-specific alignment options (moving to cell center vs random position within cell), but direct position assignment still works:
# Both work:
self.position = [5.0, 5.0]  # Direct assignment, all spaces sync
self.model.grid.move(self, target_cell, align="center")  # Space-guided movement

What doesn't work, is changing agent.cell. Why? Because if you allow multiple discrete space, to which cell would it refer?

We might allow custom cell names for each space. So one space might use agent.district, another agent.soil, etc.

  1. We're indeed just calling protected methods. While it has observer-like chararistics, it's not full pub/sub. I think it's an elegant approach.

  2. I actually think this is a powerful component. It makes it explicit.

  3. We could set a switch later if that helps for performance. For now it's by default.

I don't subscribe to core idea of that there should be one "master" space. A (discrete) space is an division of area, and each division of area could have certain (zero to n) properties.

  1. Agreed

  2. Fair, will call it agents using multiple spaces.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Dec 30, 2025

I don't subscribe to core idea of that there should be one "master" space. A (discrete) space is an division of area, and each division of area could have certain (zero to n) properties.

Can you elaborate on this because I don't understand what you are trying to say.

Is it a property of the agents to be present in multiple spaces, or is it a relationship between spaces within which agents reside?

I believe this is a crucial question we need to address, as it has profound design implications. You seem inclined to go with the first because you wrote "agents using multiple spaces". I am inclined to the second because, in my view, spaces have relations via a coordinate system that allows translation. The second also seems more GIS-like, but that is more of a hunch.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Dec 30, 2025

  1. on Mesa-geo

Mesa-geo implements a geography-first container model where a single GeoSpace object serves as the spatial world, containing an internal _AgentLayer for agents and multiple _static_layers for rasters and vector data. All spatial objects (agents, layers) inherit from GeoBase which manages coordinate reference systems (CRS) using pyproj, and when added to the GeoSpace, they're automatically converted to match its CRS with warnings.

GeoAgent objects carry their position as Shapely geometries (points, polygons, lines) rather than simple coordinates, enabling rich spatial relationships. The _AgentLayer maintains an R-tree spatial index for efficient geometric queries like "agents within distance" or "intersecting geometries", while the overall design centralizes all spatial concerns into the GeoSpace container. You don't have multiple independent spaces, but rather one geographic space with multiple data layers (raster elevation, vector districts, agent positions) all aligned to the same CRS.

This is the proper GIS way. I think this is the most powerful way to approach this, while its overkill for many models. I think the concept of neighbours is also a bit different from discrete spaces, but the question is also how important discrete-neighbour calling really is.

I also suspect performance is vastly different than our solutions, but maybe @wang-boyu can give some insights on it.

I must say I was initially leaning this mesa-geo style way, (see the mesa.world idea), and I still think it has some merit. The big questions is the performance cost.


Can you elaborate on this because I don't understand what you are trying to say.

Discrete spaces are a way to chop up a plane (or space) into fragments. As long as they are the exact same fragments, you can work with properties or property layers. As soon as you have different fragments, we call it a new "space".

I believe this is a crucial question we need to address, as it has profound design implications. You seem inclined to go with the first because you wrote "agents using multiple spaces". I am inclined to the second because, in my view, spaces have relations via a coordinate system that allows translation. The second also seems more GIS-like, but that is more of a hunch.

Oh no I fully support the latter over the former. Only, I recognize:

  • Agents might not need to make neighbourhood calculations for each space. So we can safe performance by not making them present in one.
  • Agents my not to query properties in each space. So again, potentially save performance.

So basically, while this is my take on how to do the former, mesa.world was my (GIS-style) take on how to do the latter.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Dec 30, 2025

Discrete spaces are a way to chop up a plane (or space) into fragments. As long as they are the exact same fragments, you can work with properties or property layers. As soon as you have different fragments, we call it a new "space".

Ok, that makes sense. However, spaces have a lot more functionality than property layers. Most notably, of course, distance and neighborhood stuff.

Oh no I fully support the latter over the former. Only, I recognize:

Ok, that helps a lot. But that means, in my view, that relationships between spaces should be defined at the level of spaces, not indirectly at the agent level.

Agents might not need to make neighbourhood calculations for each space. So we can save performance by not making them present in one.

I am not sure how much performance you save by this. In cell spaces, the primary cost is finding the cells. In continuous spaces, it involves a distance calculation, but we can test how this scales with the number of agents. Additionally, from a design perspective, you currently have agents opting in. An alternative might be an opt-out. Therefore, by default, if an agent is present in any space that is part of a collection of shared spaces, it has access to all spaces within that collection.

Agents my not to query properties in each space. So again, potentially save performance.

Can you give an example?

@EwoutH EwoutH mentioned this pull request Jan 14, 2026
42 tasks
@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 22, 2026

We have this and #3034, which have been useful for pathfinding, but do we need to keep them open? They are linked to from the relevant discussions. Its clear that neither will be merged.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Jan 22, 2026

Yes, we can close them. I'm good with you having the lead on space implementation. We're aligned on direction.

To be continued in #2585.

@EwoutH EwoutH closed this Jan 22, 2026
@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 22, 2026

As @tpike3 / @edgeofchaos42 wrote

Hmmm I still thing there is still a bug in meta-agents/ the logic needs to be more transparent. This is currently not how it is written and was not touched by this PR. My goal with this is to write down my understanding until i/someone else has time to address. Document for the group.

User goal - remove agent from meta-agent but keep it in the model:
remove_constituting_agents should only remove form the meta-agent not the model

So what is needed is a clean implementation for adding and removing agents from a meta agent. This probably requires some tracking mechanism inside MetaAgent.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Jan 22, 2026

I think this comment was in the wrong PR

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 22, 2026

No idea how that happend. I'll move it later to the right place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants