Source code for modelly.viz.mpl.backend

# noqa: D100
import warnings

import numpy as np
from matplotlib.figure import Figure
from matplotlib.offsetbox import OffsetImage
from PIL import Image

import modelly
from modelly.space import (
    OrthogonalMooreGrid,
    OrthogonalVonNeumannGrid,
)
from modelly.space.base import DiscreteSpace, space_agents
from modelly.viz.mpl.agent_data import (
    portrayal_style_defaults,
    rows_to_arguments,
    snapshot_style,
    style_from_dict,
    warn_dict_portrayal,
)
from modelly.viz.mpl.layers import (
    add_layer_colorbar,
    draw_hex_layer,
    draw_orthogonal_layer,
    layer_style,
)
from modelly.viz.mpl.scatter import (
    ScatterData,
    add_image_artists,
    is_image_marker,
    scatter_by_zorder,
    validate_edge_arguments,
    zoom_factor,
)
from modelly.viz.render.backend import AbstractRenderer

OrthogonalGrid = OrthogonalMooreGrid | OrthogonalVonNeumannGrid
HexGrid = modelly.space.HexGrid


CORRECTION_FACTOR_MARKER_ZOOM = 0.01
_IGNORED_MSG = "The following keys were ignored: "


[docs] class MatplotlibBackend(AbstractRenderer): """Matplotlib-based renderer for Modelly spaces. Provides visualization capabilities using Matplotlib for rendering space structures, agents, and property layers. """ def __init__(self, space_drawer): """Initialize the Matplotlib backend. Args: space_drawer: An instance of a SpaceDrawer class that handles the drawing of the space structure. """ super().__init__(space_drawer) self._active_colorbars = []
[docs] def initialize_canvas(self, ax=None): """Initialize the matplotlib canvas. Args: ax (matplotlib.axes.Axes, optional): Existing axes to use. If None, creates new figure and axes. """ if ax is None: fig = Figure(constrained_layout=True) ax = fig.add_subplot() self.fig = fig self.ax = ax
[docs] def draw_structure(self, **kwargs): """Draw the space structure using matplotlib. Args: **kwargs: Additional arguments passed to the space drawer. Checkout respective `SpaceDrawer` class on details on how to pass ``**kwargs``. Returns: The matplotlib axes with the drawn structure. """ return self.space_drawer.draw_matplotlib(self.ax, **kwargs)
[docs] def collect_agent_data(self, space, agent_portrayal, default_size=None): """Collect plotting data for all agents in the space. Args: space: The Modelly space containing agents. agent_portrayal (Callable): Function that returns AgentPortrayalStyle for each agent. default_size (float, optional): Default marker size if not specified in portrayal. Returns: dict: Dictionary containing agent plotting data arrays. """ style_fields = portrayal_style_defaults() fallback_size = default_size or style_fields.get("size") dict_portrayal_warned = False # warn once per call, not once per agent rows = [] for agent in _live_agents(space): portray_input = agent_portrayal(agent) if isinstance(portray_input, dict): if not dict_portrayal_warned: warn_dict_portrayal() dict_portrayal_warned = True position = self._get_agent_pos(agent, space) aps = style_from_dict( portray_input, position, style_fields, _IGNORED_MSG ) else: aps = portray_input if aps.x is None and aps.y is None: # Set defaults if not provided aps.x, aps.y = self._get_agent_pos(agent, space) if aps.tooltip is not None: _warn_tooltip_ignored() rows.append((aps.size or fallback_size, *snapshot_style(aps))) return rows_to_arguments(rows)
def _get_zoom_factor(self, ax, img): """Calculate zoom factor only once and cache the result.""" return zoom_factor(ax, img, CORRECTION_FACTOR_MARKER_ZOOM)
[docs] def draw_agents(self, arguments, **kwargs): """Draw agents on the backend's axes - optimized version. Args: arguments: Dictionary containing agent data arrays. **kwargs: Additional keyword arguments for customization. Checkout respective `SpaceDrawer` class on details on how to pass ``**kwargs``. Returns: matplotlib.axes.Axes: The Matplotlib Axes with the agents drawn upon it. """ if arguments["loc"].size == 0: return None data = _scatter_data(arguments, kwargs) validate_edge_arguments(arguments, kwargs) image_markers, regular_markers = _split_markers(data.marker) self.ax.get_figure().canvas.draw() for mark in image_markers: self._draw_image_marker(mark, data) for mark in regular_markers: scatter_by_zorder(self.ax, mark, data.marker == mark, data) return self.ax
def _draw_image_marker(self, mark, data: ScatterData): """Draw the agents whose marker is this image, one image per marker size.""" image = Image.open(mark) base_zoom = self._get_zoom_factor(self.ax, image) mask_marker = data.marker == mark for m_size in np.unique(data.size[mask_marker]): im = OffsetImage(image, zoom=base_zoom * m_size) im.image.axes = self.ax size_marker_mask = mask_marker & (data.size == m_size) add_image_artists(self.ax, im, size_marker_mask, data)
[docs] def draw_property_layer(self, space, property_layers, property_layer_portrayal): """Draw property layers using matplotlib backend. Args: space: The Modelly space object. property_layers (dict): Dictionary of property layers to visualize. property_layer_portrayal (Callable): Function that returns PropertyLayerStyle. Returns: tuple: (matplotlib.axes.Axes, colorbar) - The matplotlib axes and colorbar objects. """ # Draw each layer for layer_name, layer in property_layers.items(): if layer_name == "empty": continue portrayal = property_layer_portrayal(layer_name) if portrayal is None: continue data = layer.astype(float) if layer.dtype == bool else layer if not _layer_shape_matches(space, layer_name, data): continue style = _backend_layer_style(layer_name, portrayal, data) self._draw_layer(space, data, style) # Add colorbar if requested cbar = None if portrayal.colorbar: cbar = add_layer_colorbar(self.ax, style) self._active_colorbars.append(cbar) return self.ax, cbar
def _draw_layer(self, space, data, style): """Draw one property layer of an orthogonal or hex grid.""" if isinstance(space, OrthogonalGrid): draw_orthogonal_layer(self.ax, data, style) elif isinstance(space, HexGrid): hexagons = self.space_drawer.hexagons draw_hex_layer(self.ax, hexagons, data.T.ravel(), style) else: raise NotImplementedError( f"Property Layer visualization not implemented for {type(space)}" )
def _live_agents(space): """Return the agents of the space without building an AgentSet.""" # Iterate the live agents directly: space.agents would build a weak # AgentSet of the whole population for every frame. return space_agents(space) if isinstance(space, DiscreteSpace) else space.agents def _scatter_data(arguments, kwargs): """Pop loc, marker and zorder from the agent data; alpha and s stay as arguments.""" loc = arguments.pop("loc") return ScatterData( loc_x=loc[:, 0], loc_y=loc[:, 1], marker=arguments.pop("marker"), zorder=arguments.pop("zorder"), alpha=arguments["alpha"], size=arguments["s"], scatter_args=arguments, kwargs=kwargs, ) def _split_markers(marker): """Separate the image markers from the regular ones.""" image_markers = set() regular_markers = set() for mark in dict.fromkeys(marker): # first-seen order, stable z-order if is_image_marker(mark): image_markers.add(mark) else: regular_markers.add(mark) return image_markers, regular_markers def _warn_tooltip_ignored(): """Warn that tooltips only work with the Altair backend.""" warnings.warn( "The 'tooltip' attribute in AgentPortrayalStyle is " "only supported by the Altair backend. " "Tooltips will be ignored when using the Matplotlib backend.", UserWarning, stacklevel=3, # the caller of collect_agent_data() ) def _layer_shape_matches(space, layer_name, data): """Return whether the layer matches the space; warn when it does not.""" if (space.width, space.height) != data.shape: warnings.warn( f"Layer {layer_name} dimensions ({data.shape}) " f"don't match space dimensions ({space.width}, {space.height})", UserWarning, stacklevel=3, # the caller of draw_property_layer() ) return False return True def _backend_layer_style(layer_name, portrayal, data): """Resolve the style; unset vmin/vmax fall back to the data range.""" vmin = portrayal.vmin if portrayal.vmin is not None else np.min(data) vmax = portrayal.vmax if portrayal.vmax is not None else np.max(data) return layer_style( layer_name, portrayal, (vmin, vmax), f"Property Layer {layer_name} must include 'color' or 'colormap'", )