mesa icon indicating copy to clipboard operation
mesa copied to clipboard

Introduction of Space Renderer

Open Sahil-Chhoker opened this issue 7 months ago β€’ 10 comments

Summary

Introduces SpaceRenderer into the Mesa visualization system, aiming to simplify and decouple the logic of drawing agents, grids, and property layers.

Motive

Part of my GSOC commitment.

Implementation

This PR introduces 2 new files, named space_renderer.py and space_drawers.py.

  • space_renderer.py:

    • Contains the SpaceRenderer class, the main backend for Solara to render the space, agents, and property layers.
    • Key methods include:
      • draw_structure(): Draws the grid structure of the space.
      • draw_propertylayer(): Draws property layers onto the space.
      • draw_agents(): Draws agents onto the space.
      • render(): A comprehensive method to draw all components at once.
    • Manages different rendering backends (Matplotlib, Altair).
    • Handles data collection for agents for both matplotlib and altair and maps agent coordinates.
  • space_drawers.py:

    • Provides specialized drawer classes for different Mesa space types:
      • OrthogonalSpaceDrawer: For standard grid spaces.
      • HexSpaceDrawer: For hexagonal grid spaces.
      • NetworkSpaceDrawer: For network-based spaces.
      • ContinuousSpaceDrawer: For continuous spaces.
      • VoronoiSpaceDrawer: For Voronoi diagram spaces.
    • These classes are used internally by SpaceRenderer to handle the specific drawing logic for each space structure as they contain the specific grid/space drawing logic.

Usage Examples


# for matplolib
renderer = SpaceRenderer(model, ax=ax, backend='matplotlib')
renderer.draw_structure()
renderer.draw_agents(agent_portrayal)
renderer.draw_propertylayer(propertylayer_portrayal)

# if want agents on different ax:
fig, ax = plt.subplot()
renderer.draw_agnets(agent_portrayal, ax=ax)

# for altair, no need to pass ax, if passed will be ignored with a warning
renderer.SpaceRenderer(model, backend="altair")

# if want to apply different modifications to the matplotlib axes
renderer.canvas.set_title("This is a Title")
renderer.canvas.set_xlabel("X-axis")
renderer.canvas.set_ylabel("Y-axis")
renderer.canvas.set_aspect("equal")

renderer.canvas.figure.set_size_inches(10, 10)

page = SolaraViz(
    model,
    renderer,
    components=[...],
    ...
)
page

Additional Context

Checkout the discussion in #2772. Currently only contains the full coverage for matplotlib spaces, only Orthogonal spaces are available for altair (with property layers).

Summary by CodeRabbit

  • New Features
    • Introduced advanced visualization capabilities for agent-based model spaces, supporting orthogonal grids, hex grids, continuous spaces, Voronoi grids, and network grids.
    • Added a unified renderer that enables layered rendering of space structures, agents, and property layers using both matplotlib and Altair backends.
    • Enhanced the interactive visualization experience in the Solara interface with new components and rendering options.

Sahil-Chhoker avatar Jun 05 '25 08:06 Sahil-Chhoker

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small πŸ”΅ +0.9% [-0.2%, +2.2%] πŸ”΅ +0.3% [+0.0%, +0.5%]
BoltzmannWealth large πŸ”΅ +0.1% [-0.7%, +0.8%] πŸ”΅ +2.0% [+0.5%, +3.5%]
Schelling small πŸ”΅ +0.4% [+0.2%, +0.6%] πŸ”΅ -0.5% [-0.7%, -0.3%]
Schelling large πŸ”΅ +0.3% [-1.5%, +3.2%] πŸ”΅ -4.1% [-6.7%, -1.2%]
WolfSheep small πŸ”΅ +0.3% [-0.1%, +0.6%] πŸ”΅ -0.7% [-0.8%, -0.5%]
WolfSheep large πŸ”΅ +1.0% [+0.6%, +1.3%] πŸ”΅ -0.7% [-1.0%, -0.3%]
BoidFlockers small πŸ”΅ +1.6% [+0.6%, +2.6%] πŸ”΅ +0.3% [+0.1%, +0.5%]
BoidFlockers large πŸ”΅ +1.6% [+1.0%, +2.1%] πŸ”΅ -0.6% [-1.0%, -0.2%]

github-actions[bot] avatar Jun 05 '25 08:06 github-actions[bot]

@coderabbitai full review

Sahil-Chhoker avatar Jun 05 '25 08:06 Sahil-Chhoker

Walkthrough

A new SpaceRenderer class and supporting drawer modules are introduced for advanced visualization of Mesa model spaces, supporting multiple space types and rendering backends. The visualization API is updated to require and expose SpaceRenderer, with new Solara components and controllers integrated to utilize this renderer for flexible, layered agent-based model visualizations.

Changes

File(s) Change Summary
mesa/visualization/init.py Imported SpaceRenderer and added it to the __all__ list, making it a public API component.
mesa/visualization/solara_viz.py Updated Solara visualization to require a renderer argument, integrate space visualization via SpaceRenderer, and add new components.
mesa/visualization/space_drawers.py Introduced new module with drawer classes for orthogonal, hex, network, continuous, and Voronoi spaces, supporting matplotlib/Altair.
mesa/visualization/space_renderer.py Added new SpaceRenderer class for rendering model spaces, agents, and property layers with support for multiple backends and formats.
mesa/visualization/backends/init.py Added new module exporting AltairBackend and MatplotlibBackend as visualization backends.
mesa/visualization/backends/abstract_renderer.py Added abstract base class AbstractRenderer defining interface for visualization backends with methods for drawing structure, agents, and property layers.
mesa/visualization/backends/altair_backend.py Added AltairBackend class implementing visualization backend using Altair for interactive spatial visualizations.
mesa/visualization/backends/matplotlib_backend.py Added MatplotlibBackend class implementing visualization backend using Matplotlib for static spatial visualizations.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant SolaraViz
    participant SpaceRenderer
    participant Model

    User->>SolaraViz: Initialize with Model and SpaceRenderer
    SolaraViz->>Model: Access model's space/grid
    SolaraViz->>SpaceRenderer: Set space to model's space
    User->>SolaraViz: Interact (step, reset, etc.)
    SolaraViz->>SpaceRenderer: render(agent_portrayal, propertylayer_portrayal)
    SpaceRenderer->>Model: Collect agent/property data
    SpaceRenderer->>SpaceRenderer: Draw structure, agents, property layers
    SpaceRenderer-->>SolaraViz: Return visualization component
    SolaraViz-->>User: Display updated visualization

Poem

A rabbit with a painter's flair,
Now draws your grids with utmost care.
Hex, network, or orthogonal lines,
Spaces renderedβ€”how it shines!
With Solara's help, the models gleam,
Agents dance in a vivid dream.
πŸ‡βœ¨

✨ Finishing Touches
  • [ ] πŸ“ Generate Docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share
πŸͺ§ Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

coderabbitai[bot] avatar Jun 05 '25 09:06 coderabbitai[bot]

@Sahil-Chhoker some thoughts for you (let me know what you think)--

Goal: Make drawing more extensible so it is easier to integrate other visualization libraries (e.g. plotly). - How: Possibly build a base_render or somethingclass so it is easier to integrate other visualization libraries

Goal: Speed up - How: With the above could lazy load the import modules so only the library being used is called - How: Other ways to optimize loading of agents but i think this would go beyond the scope of this effort and may be a next year GSOC thing.

Goal: Easier user build How hard would it be to get this down to one line for quick and dirty visualizations, and then users can expand it for more customization

renderer = SpaceRenderer(model, ax=ax, backend='matplotlib')
renderer.draw_structure()
renderer.draw_agents(agent_portrayal)
renderer.draw_propertylayer(propertylayer_portrayal)

Can users still pass in custom drawing like with @Holzhauer and #2799

My biggest concern is we need to break it and make it more extensible.

I will say your code is excellent. The amount of work, and your code and comments are incredibly clean.

Thoughts @EwoutH , @quaquel , @jackiekazil

tpike3 avatar Jun 10 '25 10:06 tpike3

Goal: Make drawing more extensible so it is easier to integrate other visualization libraries (e.g. plotly). - How: Possibly build a base_render or somethingclass so it is easier to integrate other visualization libraries

One really quick idea that comes to mind after hearing this is having the main SpaceRenderer that holds the reference to two MatplotlibBackend and AltairBackend (and potentially PlotlyBackend in the future). The main drawing components are divided into the library specific backends and common code stays, well, common in the SpaceRenderer.

Goal: Speed up - How: With the above could lazy load the import modules so only the library being used is called - How: Other ways to optimize loading of agents but i think this would go beyond the scope of this effort and may be a next year GSOC thing.

Will have to think it through.

Goal: Easier user build How hard would it be to get this down to one line for quick and dirty visualizations, and then users can expand it for more customization

Just do:

renderer.render(agent_portrayal, propertylayer_portrayal)
# renderer.render() only draws structure
# renderer.render(agent_portrayal) draws agents on structure
# renderer.render(propertylayer_portrayal) draws property layers on structure

Can users still pass in custom drawing like with @Holzhauer and #2799

Yes the components parameter in the solara_viz retains and is the best possible way in my opinion to make and draw custom components rather than having some sloppy cover for it.

My biggest concern is we need to break it and make it more extensible.

This is still experimental, so its okay.

I will say your code is excellent. The amount of work, and your code and comments are incredibly clean.

Thanks!

Sahil-Chhoker avatar Jun 10 '25 10:06 Sahil-Chhoker

A more concrete form of my idea, @tpike3 let me know your thoughts.

class AbstractRendererBackend(ABC):
    """Abstract base class for a visualization backend."""

    def __init__(self, space, space_drawer, map_coords_func):
        self.space = space
        self.space_drawer = space_drawer
        self.map_coords_func = map_coords_func
        self.space_mesh = None
        self.agent_mesh = None
        self.propertylayer_mesh = None
        self.canvas = None

    @abstractmethod
    def initialize_canvas(self, **kwargs):
        """Set up the drawing canvas (e.g., Matplotlib Axes, Altair Chart)."""

    @abstractmethod
    def draw_structure(self, **kwargs):
        """Draw the space structure (grid lines, etc.)."""

    @abstractmethod
    def draw_agents(self, agent_portrayal, **kwargs):
        """Collect agent data and draw them."""

    @abstractmethod
    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        """Draw property layers."""

    @abstractmethod
    def render(self, agent_portrayal, propertylayer_portrayal, **kwargs):
        """Render the entire visualization."""

    def clear_meshes(self):
        """Clear all stored mesh objects."""
        self.space_mesh = None
        self.agent_mesh = None
        self.propertylayer_mesh = None


class MatplotlibBackend(AbstractRendererBackend):
    """A renderer that uses Matplotlib."""

    def initialize_canvas(self, ax=None, **kwargs):
        ...

    def draw_structure(self, **kwargs):
        ...

    def _collect_agent_data(self, agent_portrayal, default_size):
        ...

    def _draw_agents_on_ax(self, ax, arguments, **kwargs):
        ...

    def draw_agents(self, agent_portrayal, **kwargs):
        ...

    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        ...

    def render(self, agent_portrayal, propertylayer_portrayal, **kwargs):
        ...
    
    ...


class AltairBackend(AbstractRendererBackend):
    """A renderer that uses Altair."""

    def initialize_canvas(self, **kwargs):
        ...

    def draw_structure(self, **kwargs):
        ...

    def _collect_agent_data(self, agent_portrayal, default_size):
        ...

    def draw_agents(self, agent_portrayal, **kwargs):
        ...
        
    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        ... 

    def render(self, agent_portrayal, propertylayer_portrayal, **kwargs):
        ...


class SpaceRenderer:
    def __init__(self, model, backend: str = "matplotlib", **kwargs):
        self.space = getattr(model, "grid", getattr(model, "space", None))

        self.space_drawer = self._get_space_drawer()
        self.backend = self._create_backend(backend, **kwargs)

    def _create_backend(self, backend_name: str, **kwargs):
        """Factory method to create the appropriate backend."""
        if backend_name == "matplotlib":
            backend_cls = MatplotlibBackend
        elif backend_name == "altair":
            backend_cls = AltairBackend
        else:
            raise ValueError(f"Unsupported backend: '{backend_name}'")
        
        backend = backend_cls(self.space, self.space_drawer, self._map_coordinates)
        backend.initialize_canvas(**kwargs)
        return backend

    def _get_space_drawer(self):
        ...

    def _map_coordinates(self, arguments):
        ...

    def draw_structure(self, **kwargs):
        return self.backend.draw_structure(**kwargs)

    def draw_agents(self, agent_portrayal, **kwargs):
        return self.backend.draw_agents(agent_portrayal, **kwargs)

    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        return self.backend.draw_propertylayer(propertylayer_portrayal, **kwargs)

    def render(self, agent_portrayal=None, propertylayer_portrayal=None, **kwargs):
        return self.backend.render(agent_portrayal, propertylayer_portrayal, **kwargs)

    @property
    def canvas(self):
        return self.backend.canvas

    def clear_meshes(self):
        self.backend.clear_meshes()

Sahil-Chhoker avatar Jun 11 '25 08:06 Sahil-Chhoker

@Corvince hope you are doing well! I think you might find this (very) interesting.

EwoutH avatar Jun 11 '25 09:06 EwoutH

A more concrete form of my idea, @tpike3 let me know your thoughts.

class AbstractRendererBackend(ABC):
    """Abstract base class for a visualization backend."""

    def __init__(self, space, space_drawer, map_coords_func):
        self.space = space
        self.space_drawer = space_drawer
        self.map_coords_func = map_coords_func
        self.space_mesh = None
        self.agent_mesh = None
        self.propertylayer_mesh = None
        self.canvas = None

    @abstractmethod
    def initialize_canvas(self, **kwargs):
        """Set up the drawing canvas (e.g., Matplotlib Axes, Altair Chart)."""

    @abstractmethod
    def draw_structure(self, **kwargs):
        """Draw the space structure (grid lines, etc.)."""

    @abstractmethod
    def draw_agents(self, agent_portrayal, **kwargs):
        """Collect agent data and draw them."""

    @abstractmethod
    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        """Draw property layers."""

    @abstractmethod
    def render(self, agent_portrayal, propertylayer_portrayal, **kwargs):
        """Render the entire visualization."""

    def clear_meshes(self):
        """Clear all stored mesh objects."""
        self.space_mesh = None
        self.agent_mesh = None
        self.propertylayer_mesh = None


class MatplotlibBackend(AbstractRendererBackend):
    """A renderer that uses Matplotlib."""

    def initialize_canvas(self, ax=None, **kwargs):
        ...

    def draw_structure(self, **kwargs):
        ...

    def _collect_agent_data(self, agent_portrayal, default_size):
        ...

    def _draw_agents_on_ax(self, ax, arguments, **kwargs):
        ...

    def draw_agents(self, agent_portrayal, **kwargs):
        ...

    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        ...

    def render(self, agent_portrayal, propertylayer_portrayal, **kwargs):
        ...
    
    ...


class AltairBackend(AbstractRendererBackend):
    """A renderer that uses Altair."""

    def initialize_canvas(self, **kwargs):
        ...

    def draw_structure(self, **kwargs):
        ...

    def _collect_agent_data(self, agent_portrayal, default_size):
        ...

    def draw_agents(self, agent_portrayal, **kwargs):
        ...
        
    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        ... 

    def render(self, agent_portrayal, propertylayer_portrayal, **kwargs):
        ...


class SpaceRenderer:
    def __init__(self, model, backend: str = "matplotlib", **kwargs):
        self.space = getattr(model, "grid", getattr(model, "space", None))

        self.space_drawer = self._get_space_drawer()
        self.backend = self._create_backend(backend, **kwargs)

    def _create_backend(self, backend_name: str, **kwargs):
        """Factory method to create the appropriate backend."""
        if backend_name == "matplotlib":
            backend_cls = MatplotlibBackend
        elif backend_name == "altair":
            backend_cls = AltairBackend
        else:
            raise ValueError(f"Unsupported backend: '{backend_name}'")
        
        backend = backend_cls(self.space, self.space_drawer, self._map_coordinates)
        backend.initialize_canvas(**kwargs)
        return backend

    def _get_space_drawer(self):
        ...

    def _map_coordinates(self, arguments):
        ...

    def draw_structure(self, **kwargs):
        return self.backend.draw_structure(**kwargs)

    def draw_agents(self, agent_portrayal, **kwargs):
        return self.backend.draw_agents(agent_portrayal, **kwargs)

    def draw_propertylayer(self, propertylayer_portrayal, **kwargs):
        return self.backend.draw_propertylayer(propertylayer_portrayal, **kwargs)

    def render(self, agent_portrayal=None, propertylayer_portrayal=None, **kwargs):
        return self.backend.render(agent_portrayal, propertylayer_portrayal, **kwargs)

    @property
    def canvas(self):
        return self.backend.canvas

    def clear_meshes(self):
        self.backend.clear_meshes()

I like it!

You should be able to do a backend registry for SpaceRender so we can avoid a long string of if-else statements. See a gpt generated approach below (so be skeptical). However, I think this approach with each different backend in their own file will make it more intuitively obvious and easier for others or us to extend later on.

One nitpick is the naming conventions maybe just AbstractRender and then MatplotlibRender, AltairRender, but I am never happy with naming conventions, so dont waste cycles on this.

 _BACKENDS: dict[str, type[AbstractRendererBackend]] = {}

def register_backend(name: str):
    def decorator(cls):
        _BACKENDS[name] = cls
        return cls
    return decorator

@register_backend("matplotlib")
class MatplotlibBackend(AbstractRendererBackend):
    ...

backend_cls = _BACKENDS.get(backend_name)
if backend_cls is None:
    raise ValueError(...)

tpike3 avatar Jun 11 '25 09:06 tpike3

Got it, will start working on it.

Sahil-Chhoker avatar Jun 11 '25 11:06 Sahil-Chhoker

@tpike3, I've added both the backends separately and I think they work fine. Let me know your thoughts. Also about the backend registry, my personal view that at most we can have 3 backends (current ones + plotly) because these are all the libraries solara supports, so I don't feel the need to create such complicated system to select a backend system, but this is just my personal opinion, let me know if you feel otherwise.

Sahil-Chhoker avatar Jun 15 '25 12:06 Sahil-Chhoker

@Sahil-Chhok I am on holiday right now, so give me a couple more days to review. However, I do like the new set up

That is a fairpoint about Solara and only having three backends, so no need for a registry.

Is there anyway to support backward compatibility with current visualization or is that an impossible ask?

tpike3 avatar Jun 17 '25 10:06 tpike3

I am on holiday right now, so give me a couple more days to review.

Sure, just let me know your thoughts whenever you have time.

Is there anyway to support backward compatibility with current visualization or is that an impossible ask?

I've just added new functionality, so the previous one works as is, we can give a deprecation warning there and ask for a shift.

Sahil-Chhoker avatar Jun 17 '25 10:06 Sahil-Chhoker

@tpike3, how should I go about the tests? If I make the SpaceRenderer optional in SolaraViz this will enable us to have backwards compatibility and all the current tests will pass and new tests for SpaceRenderer can be written in a separate file but they will be the exact copy of currents tests, just with SpaceRenderer in them.

Or I can just update the current tests to include the SpaceRenderer but that will prevent backwards compatibility.

(btw I am talking about the solaraviz tests)

Sahil-Chhoker avatar Jun 19 '25 03:06 Sahil-Chhoker

I suggest keeping it backward compatible, so we don't need to go to Mesa 4 to use this new stuff. Having duplicate tests is a small price to pay for this, in my view. When moving to Mesa 4, we can just delete the old tests.

quaquel avatar Jun 19 '25 06:06 quaquel

I agree with @quaquel , backwards compatibility is critical and some redundancies in tests are a small price to pay

tpike3 avatar Jun 19 '25 10:06 tpike3

@coderabbitai full review

tpike3 avatar Jun 19 '25 10:06 tpike3

βœ… Actions performed

Full review triggered.

coderabbitai[bot] avatar Jun 19 '25 10:06 coderabbitai[bot]

@Sahil-Chhoker I think you are way ahead of schedule! I think the only thing left for this is PR is the backward compatibility and some of the coderabbitai suggestions.

@EwoutH, @quaquel @jackiekazil -- any other suggestions or thoughts?

tpike3 avatar Jun 19 '25 11:06 tpike3

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small πŸ”΅ -0.4% [-1.2%, +0.5%] πŸ”΅ +0.5% [+0.3%, +0.6%]
BoltzmannWealth large πŸ”΅ -1.3% [-2.1%, -0.5%] 🟒 -6.5% [-9.6%, -3.0%]
Schelling small πŸ”΅ +0.5% [+0.3%, +0.7%] πŸ”΅ +1.0% [+0.8%, +1.1%]
Schelling large πŸ”΅ -2.0% [-2.8%, -0.9%] 🟒 -7.7% [-11.9%, -3.4%]
WolfSheep small πŸ”΅ -0.2% [-0.5%, +0.1%] πŸ”΅ -0.1% [-0.3%, +0.1%]
WolfSheep large πŸ”΅ -0.6% [-1.7%, +0.4%] πŸ”΅ -2.2% [-3.6%, -1.1%]
BoidFlockers small πŸ”΅ -2.1% [-2.6%, -1.5%] πŸ”΅ -0.1% [-0.3%, +0.0%]
BoidFlockers large πŸ”΅ -1.9% [-2.6%, -1.3%] πŸ”΅ +0.4% [+0.1%, +0.7%]

github-actions[bot] avatar Jun 20 '25 17:06 github-actions[bot]

Thanks for your continued work on this, glad to see it's ready for review!

I will try to review it somewhere this weekend.

EwoutH avatar Jun 20 '25 22:06 EwoutH

Great work on the SpaceRenderer system! The backend abstraction and separation of concerns look really solid. I also really like the API, it's quite elegant overall.

I wanted to discuss one architectural question: should we consider backend-specific subclasses instead of the current string-based backend selection?

Current approach:

renderer = SpaceRenderer(model, backend="matplotlib")
renderer.draw_structure(figsize=(10, 8))  # Works with matplotlib, ignored by altair

Potential subclass approach:

renderer = MatplotlibSpaceRenderer(model)  
renderer.draw_structure(figsize=(10, 8))   # Type-safe, IDE knows this parameter
renderer.save_figure("output.png")        # Backend-specific methods possible

The main advantage would be type safety - IDEs could provide proper autocomplete and catch invalid parameters at development time rather than runtime. Each backend could also expose specific methods without cluttering the base API.

We could maintain the current simple API with a factory function:

# Simple usage (returns appropriate subclass)
renderer = SpaceRenderer(model, backend="matplotlib")

# Advanced usage (direct class access)
from mesa.visualization.backends import MatplotlibSpaceRenderer
renderer = MatplotlibSpaceRenderer(model)

The common functionality would stay in the base SpaceRenderer class, so code duplication would be minimal. The current architecture already has the backend abstraction that would make this transition straightforward.

The trade-off is slightly more complexity for advanced users who want full type safety, though the factory function would keep the simple cases unchanged.

EwoutH avatar Jun 23 '25 15:06 EwoutH

Thanks for the praise!

Yeah, the current design is intentional, it’s meant to get most users up and running with visualizations quickly, without needing to worry about backend specifics.

The whole idea behind SpaceRenderer is to keep things backend-agnostic. If we start adding subclass-specific methods like matplotlib_renderer.set_grid_lines() or altair_renderer.set_interactive_tooltip(), it kind of defeats the purpose β€” switching backends would suddenly become messy instead of seamless.

That said, I totally get the appeal of type safety and IDE hints. To give advanced users that flexibility, I’ve already exposed the canvas (ax for Matplotlib, chart for Altair) through SpaceRenderer. So if someone really needs to tweak things at a deeper level, they still can. Those changes already show up in the frontend for Matplotlib, and I’m working on making it work just as smoothly for Altair too.

Sahil-Chhoker avatar Jun 24 '25 05:06 Sahil-Chhoker

Don't different backends fundamentally have different capabilities? And wouldn't it make sense to proactively inform users of those different capabilities? Because now, if you switch backends, and have used some functionality that is supported by one but not by another, you will get a strange, unknown and hard error, instead of an informative, explicit error.

To quote PEP 20:

Explicit is better than implicit.

Errors should never pass silently.

EwoutH avatar Jun 24 '25 06:06 EwoutH

In the runtime error thrown by SolaraViz, it can be clearly seen where the code is wrong and what is not supported.

Sahil-Chhoker avatar Jun 24 '25 07:06 Sahil-Chhoker

I have been wondering about this issue ever since I did the cleanup over half a year ago. I see good arguments for both approaches. What I like about @Sahil-Chhoker's approach is that you offer a backend agnostic common interface for default operations, while giving access to the underlying backend-specific details for further fine-tuning. For someone not familiar with the specifics of either back-end, this is convenient.

However, given the fundamentally different API philosophy behind matplotlib and altair, I have been worried about how much of a common interface can be created.

quaquel avatar Jun 24 '25 07:06 quaquel

@tpike3, the docstrings really were a disaster. I've tried to improve them, tell me if there is still anything concerning.

Sahil-Chhoker avatar Jun 27 '25 15:06 Sahil-Chhoker

@Sahil-Chhoker Running your code in the examples\basic\boltzmann_wealth_model I had some issues, but it may be the way I used it. (Thanks for the recommendation, I should have done this much earlier.)

1 - It wasn't backwards compatible, I could not run the existing code with your changes out of the box (could be because we are still midstride) 2 - Running with the new code, based on your usage example (which I may have misunderstood ) I was getting 2 plots.

Below is my examples\basic\boltzmann_wealth_model\app.py. I could also push to your branch if that is easier.

from mesa.examples.basic.boltzmann_wealth_model.model import BoltzmannWealth
from mesa.mesa_logging import INFO, log_to_stderr
from mesa.visualization import (
    SolaraViz,
    SpaceRenderer,
)

log_to_stderr(INFO)


def agent_portrayal(agent):
    color = agent.wealth  # we are using a colormap to translate wealth to color
    return {"color": color}


model_params = {
    "seed": {
        "type": "InputText",
        "value": 42,
        "label": "Random Seed",
    },
    "n": {
        "type": "SliderInt",
        "value": 50,
        "label": "Number of agents:",
        "min": 10,
        "max": 100,
        "step": 1,
    },
    "width": 10,
    "height": 10,
}

# Create initial model instance
model = BoltzmannWealth(50, 10, 10)

renderer = SpaceRenderer(model, backend='matplotlib')
#renderer.draw_structure()
#renderer.draw_agents(agent_portrayal)
#renderer.draw_propertylayer(propertylayer_portrayal)

# Create the SolaraViz page. This will automatically create a server and display the
# visualization elements in a web browser.
# Display it using the following command in the example directory:
# solara run app.py
# It will automatically update and display any changes made to this file
page = SolaraViz(
    model,
    renderer, 
    model_params=model_params,
    name="Boltzmann Wealth Model",
)
page  # noqa

tpike3 avatar Jun 28 '25 10:06 tpike3

@tpike3, thanks for checking. Missing out these little things is what I was afraid of.

  1. I made space renderer optional but never added the null check so that's covered now.
  2. It's default solara viz behavior (existed before this PR) that if there is no components list passed, it automatically draws a altair space, it will not happen if you pass a component's list even if it's empty. Should I remove this functionality?

Sahil-Chhoker avatar Jun 28 '25 10:06 Sahil-Chhoker

@tpike3, thanks for checking. Missing out these little things is what I was afraid of.

  1. I made space renderer optional but never added the null check so that's covered now.
  2. It's default solara viz behavior (existed before this PR) that if there is no components list passed, it automatically draws a altair space, it will not happen if you pass a component's list even if it's empty. Should I remove this functionality?

@Sahil-Chhoker Thanks --- for #2 we need to keep it backwards compatible. There may be a couple ways to do this, but off the top of my head, can you use renderer to override the lack of components?

tpike3 avatar Jun 29 '25 10:06 tpike3

@Sahil-Chhoker running through some more variants -

  • The Solara resizing feature of the plots is better, but still clunky, could you take a look at the again

I pretty sure you have said this, but you need to update the Altair plot. Both backends should produce the same look and feel

Right now

Plot Matplotlib Altair
position in center of x,y grid on intersection of x,y
labels no x y labels has x,y labels
legend no legend has legend

Also for the boltzmann wealth model altair is just showing circles and has a legend for opacity but the circles are not filling in.

Still some very good work

tpike3 avatar Jun 29 '25 11:06 tpike3