diff --git a/mesa/experimental/UserParam.py b/mesa/experimental/UserParam.py deleted file mode 100644 index 9cf5585e802..00000000000 --- a/mesa/experimental/UserParam.py +++ /dev/null @@ -1,67 +0,0 @@ -"""helper classes.""" - - -class UserParam: # noqa: D101 - _ERROR_MESSAGE = "Missing or malformed inputs for '{}' Option '{}'" - - def maybe_raise_error(self, param_type, valid): # noqa: D102 - if valid: - return - msg = self._ERROR_MESSAGE.format(param_type, self.label) - raise ValueError(msg) - - -class Slider(UserParam): - """A number-based slider input with settable increment. - - Example: - slider_option = Slider("My Slider", value=123, min=10, max=200, step=0.1) - - Args: - label: The displayed label in the UI - value: The initial value of the slider - min: The minimum possible value of the slider - max: The maximum possible value of the slider - step: The step between min and max for a range of possible values - dtype: either int or float - """ - - def __init__( - self, - label="", - value=None, - min=None, - max=None, - step=1, - dtype=None, - ): - """Slider class. - - Args: - label: The displayed label in the UI - value: The initial value of the slider - min: The minimum possible value of the slider - max: The maximum possible value of the slider - step: The step between min and max for a range of possible values - dtype: either int or float - """ - self.label = label - self.value = value - self.min = min - self.max = max - self.step = step - - # Validate option type to make sure values are supplied properly - valid = not (self.value is None or self.min is None or self.max is None) - self.maybe_raise_error("slider", valid) - - if dtype is None: - self.is_float_slider = self._check_values_are_float(value, min, max, step) - else: - self.is_float_slider = dtype is float - - def _check_values_are_float(self, value, min, max, step): - return any(isinstance(n, float) for n in (value, min, max, step)) - - def get(self, attr): # noqa: D102 - return getattr(self, attr) diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py index 5d396b5dc77..42a510cb9c6 100644 --- a/mesa/experimental/__init__.py +++ b/mesa/experimental/__init__.py @@ -1,13 +1,5 @@ """Experimental init.""" -from mesa.experimental import cell_space +from mesa.experimental import cell_space, devs -try: - from .solara_viz import JupyterViz, Slider, SolaraViz, make_text - - __all__ = ["cell_space", "JupyterViz", "Slider", "SolaraViz", "make_text"] -except ImportError: - print( - "Could not import SolaraViz. If you need it, install with 'pip install --pre mesa[viz]'" - ) - __all__ = ["cell_space"] +__all__ = ["cell_space", "devs"] diff --git a/mesa/experimental/components/altair.py b/mesa/experimental/components/altair.py deleted file mode 100644 index aaf2f2a1a4c..00000000000 --- a/mesa/experimental/components/altair.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Altair components.""" - -import contextlib - -import solara - -with contextlib.suppress(ImportError): - import altair as alt - - -@solara.component -def SpaceAltair(model, agent_portrayal, dependencies: list[any] | None = None): - """A component that renders a Space using Altair. - - Args: - model: a model instance - agent_portrayal: agent portray specification - dependencies: optional list of dependencies (currently not used) - - """ - space = getattr(model, "grid", None) - if space is None: - # Sometimes the space is defined as model.space instead of model.grid - space = model.space - chart = _draw_grid(space, agent_portrayal) - solara.FigureAltair(chart) - - -def _draw_grid(space, agent_portrayal): - def portray(g): - all_agent_data = [] - for content, (x, y) in g.coord_iter(): - if not content: - continue - if not hasattr(content, "__iter__"): - # Is a single grid - content = [content] # noqa: PLW2901 - for agent in content: - # use all data from agent portrayal, and add x,y coordinates - agent_data = agent_portrayal(agent) - agent_data["x"] = x - agent_data["y"] = y - all_agent_data.append(agent_data) - return all_agent_data - - all_agent_data = portray(space) - invalid_tooltips = ["color", "size", "x", "y"] - - encoding_dict = { - # no x-axis label - "x": alt.X("x", axis=None, type="ordinal"), - # no y-axis label - "y": alt.Y("y", axis=None, type="ordinal"), - "tooltip": [ - alt.Tooltip(key, type=alt.utils.infer_vegalite_type([value])) - for key, value in all_agent_data[0].items() - if key not in invalid_tooltips - ], - } - has_color = "color" in all_agent_data[0] - if has_color: - encoding_dict["color"] = alt.Color("color", type="nominal") - has_size = "size" in all_agent_data[0] - if has_size: - encoding_dict["size"] = alt.Size("size", type="quantitative") - - chart = ( - alt.Chart( - alt.Data(values=all_agent_data), encoding=alt.Encoding(**encoding_dict) - ) - .mark_point(filled=True) - .properties(width=280, height=280) - # .configure_view(strokeOpacity=0) # hide grid/chart lines - ) - # This is the default value for the marker size, which auto-scales - # according to the grid area. - if not has_size: - length = min(space.width, space.height) - chart = chart.mark_point(size=30000 / length**2, filled=True) - - return chart diff --git a/mesa/experimental/components/matplotlib.py b/mesa/experimental/components/matplotlib.py deleted file mode 100644 index bb3b9854193..00000000000 --- a/mesa/experimental/components/matplotlib.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Support for using matplotlib to draw spaces.""" - -from collections import defaultdict - -import networkx as nx -import solara -from matplotlib.figure import Figure -from matplotlib.ticker import MaxNLocator - -import mesa -from mesa.experimental.cell_space import VoronoiGrid - - -@solara.component -def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = None): - """A component for rendering a space using Matplotlib. - - Args: - model: a model instance - agent_portrayal: a specification of how to portray an agent. - dependencies: list of dependencies. - - """ - space_fig = Figure() - space_ax = space_fig.subplots() - space = getattr(model, "grid", None) - if space is None: - # Sometimes the space is defined as model.space instead of model.grid - space = model.space - if isinstance(space, mesa.space.NetworkGrid): - _draw_network_grid(space, space_ax, agent_portrayal) - elif isinstance(space, mesa.space.ContinuousSpace): - _draw_continuous_space(space, space_ax, agent_portrayal) - elif isinstance(space, VoronoiGrid): - _draw_voronoi(space, space_ax, agent_portrayal) - else: - _draw_grid(space, space_ax, agent_portrayal) - solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies) - - -# matplotlib scatter does not allow for multiple shapes in one call -def _split_and_scatter(portray_data, space_ax): - grouped_data = defaultdict(lambda: {"x": [], "y": [], "s": [], "c": []}) - - # Extract data from the dictionary - x = portray_data["x"] - y = portray_data["y"] - s = portray_data["s"] - c = portray_data["c"] - m = portray_data["m"] - - if not (len(x) == len(y) == len(s) == len(c) == len(m)): - raise ValueError( - "Length mismatch in portrayal data lists: " - f"x: {len(x)}, y: {len(y)}, size: {len(s)}, " - f"color: {len(c)}, marker: {len(m)}" - ) - - # Group the data by marker - for i in range(len(x)): - marker = m[i] - grouped_data[marker]["x"].append(x[i]) - grouped_data[marker]["y"].append(y[i]) - grouped_data[marker]["s"].append(s[i]) - grouped_data[marker]["c"].append(c[i]) - - # Plot each group with the same marker - for marker, data in grouped_data.items(): - space_ax.scatter(data["x"], data["y"], s=data["s"], c=data["c"], marker=marker) - - -def _draw_grid(space, space_ax, agent_portrayal): - def portray(g): - x = [] - y = [] - s = [] # size - c = [] # color - m = [] # shape - for i in range(g.width): - for j in range(g.height): - content = g._grid[i][j] - if not content: - continue - if not hasattr(content, "__iter__"): - # Is a single grid - content = [content] - for agent in content: - data = agent_portrayal(agent) - x.append(i) - y.append(j) - - # This is the default value for the marker size, which auto-scales - # according to the grid area. - default_size = (180 / max(g.width, g.height)) ** 2 - # establishing a default prevents misalignment if some agents are not given size, color, etc. - size = data.get("size", default_size) - s.append(size) - color = data.get("color", "tab:blue") - c.append(color) - mark = data.get("shape", "o") - m.append(mark) - out = {"x": x, "y": y, "s": s, "c": c, "m": m} - return out - - space_ax.set_xlim(-1, space.width) - space_ax.set_ylim(-1, space.height) - _split_and_scatter(portray(space), space_ax) - - -def _draw_network_grid(space, space_ax, agent_portrayal): - graph = space.G - pos = nx.spring_layout(graph, seed=0) - nx.draw( - graph, - ax=space_ax, - pos=pos, - **agent_portrayal(graph), - ) - - -def _draw_continuous_space(space, space_ax, agent_portrayal): - def portray(space): - x = [] - y = [] - s = [] # size - c = [] # color - m = [] # shape - for agent in space._agent_to_index: - data = agent_portrayal(agent) - _x, _y = agent.pos - x.append(_x) - y.append(_y) - - # This is matplotlib's default marker size - default_size = 20 - # establishing a default prevents misalignment if some agents are not given size, color, etc. - size = data.get("size", default_size) - s.append(size) - color = data.get("color", "tab:blue") - c.append(color) - mark = data.get("shape", "o") - m.append(mark) - out = {"x": x, "y": y, "s": s, "c": c, "m": m} - return out - - # Determine border style based on space.torus - border_style = "solid" if not space.torus else (0, (5, 10)) - - # Set the border of the plot - for spine in space_ax.spines.values(): - spine.set_linewidth(1.5) - spine.set_color("black") - spine.set_linestyle(border_style) - - width = space.x_max - space.x_min - x_padding = width / 20 - height = space.y_max - space.y_min - y_padding = height / 20 - space_ax.set_xlim(space.x_min - x_padding, space.x_max + x_padding) - space_ax.set_ylim(space.y_min - y_padding, space.y_max + y_padding) - - # Portray and scatter the agents in the space - _split_and_scatter(portray(space), space_ax) - - -def _draw_voronoi(space, space_ax, agent_portrayal): - def portray(g): - x = [] - y = [] - s = [] # size - c = [] # color - - for cell in g.all_cells: - for agent in cell.agents: - data = agent_portrayal(agent) - x.append(cell.coordinate[0]) - y.append(cell.coordinate[1]) - if "size" in data: - s.append(data["size"]) - if "color" in data: - c.append(data["color"]) - out = {"x": x, "y": y} - # This is the default value for the marker size, which auto-scales - # according to the grid area. - out["s"] = s - if len(c) > 0: - out["c"] = c - - return out - - x_list = [i[0] for i in space.centroids_coordinates] - y_list = [i[1] for i in space.centroids_coordinates] - x_max = max(x_list) - x_min = min(x_list) - y_max = max(y_list) - y_min = min(y_list) - - width = x_max - x_min - x_padding = width / 20 - height = y_max - y_min - y_padding = height / 20 - space_ax.set_xlim(x_min - x_padding, x_max + x_padding) - space_ax.set_ylim(y_min - y_padding, y_max + y_padding) - space_ax.scatter(**portray(space)) - - for cell in space.all_cells: - polygon = cell.properties["polygon"] - space_ax.fill( - *zip(*polygon), - alpha=min(1, cell.properties[space.cell_coloring_property]), - c="red", - ) # Plot filled polygon - space_ax.plot(*zip(*polygon), color="black") # Plot polygon edges in red - - -@solara.component -def PlotMatplotlib(model, measure, dependencies: list[any] | None = None): - """A solara component for creating a matplotlib figure. - - Args: - model: Model instance - measure: measure to plot - dependencies: list of additional dependencies - - """ - fig = Figure() - ax = fig.subplots() - df = model.datacollector.get_model_vars_dataframe() - if isinstance(measure, str): - ax.plot(df.loc[:, measure]) - ax.set_ylabel(measure) - elif isinstance(measure, dict): - for m, color in measure.items(): - ax.plot(df.loc[:, m], label=m, color=color) - fig.legend() - elif isinstance(measure, list | tuple): - for m in measure: - ax.plot(df.loc[:, m], label=m) - fig.legend() - # Set integer x axis - ax.xaxis.set_major_locator(MaxNLocator(integer=True)) - solara.FigureMatplotlib(fig, dependencies=dependencies) diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index 57749f038f4..612c2a8d0a2 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -9,12 +9,13 @@ import numbers from collections.abc import Callable -from typing import Any - -from mesa import Model +from typing import TYPE_CHECKING, Any from .eventlist import EventList, Priority, SimulationEvent +if TYPE_CHECKING: + from mesa import Model + class Simulator: """The Simulator controls the time advancement of the model. diff --git a/mesa/experimental/solara_viz.py b/mesa/experimental/solara_viz.py deleted file mode 100644 index 135563bb2db..00000000000 --- a/mesa/experimental/solara_viz.py +++ /dev/null @@ -1,453 +0,0 @@ -"""Mesa visualization module for creating interactive model visualizations. - -This module provides components to create browser- and Jupyter notebook-based visualizations of -Mesa models, allowing users to watch models run step-by-step and interact with model parameters. - -Key features: - - SolaraViz: Main component for creating visualizations, supporting grid displays and plots - - ModelController: Handles model execution controls (step, play, pause, reset) - - UserInputs: Generates UI elements for adjusting model parameters - - Card: Renders individual visualization elements (space, measures) - -The module uses Solara for rendering in Jupyter notebooks or as standalone web applications. -It supports various types of visualizations including matplotlib plots, agent grids, and -custom visualization components. - -Usage: - 1. Define an agent_portrayal function to specify how agents should be displayed - 2. Set up model_params to define adjustable parameters - 3. Create a SolaraViz instance with your model, parameters, and desired measures - 4. Display the visualization in a Jupyter notebook or run as a Solara app - -See the Visualization Tutorial and example models for more details. -""" - -import threading - -import reacton.ipywidgets as widgets -import solara -from solara.alias import rv - -import mesa.experimental.components.altair as components_altair -import mesa.experimental.components.matplotlib as components_matplotlib -from mesa.experimental.UserParam import Slider - - -# TODO: Turn this function into a Solara component once the current_step.value -# dependency is passed to measure() -def Card( - model, measures, agent_portrayal, space_drawer, dependencies, color, layout_type -): - """Create a card component for visualizing model space or measures. - - Args: - model: The Mesa model instance - measures: List of measures to be plotted - agent_portrayal: Function to define agent appearance - space_drawer: Method to render agent space - dependencies: List of dependencies for updating the visualization - color: Background color of the card - layout_type: Type of layout (Space or Measure) - - Returns: - rv.Card: A card component containing the visualization - """ - with rv.Card( - style_=f"background-color: {color}; width: 100%; height: 100%" - ) as main: - if "Space" in layout_type: - rv.CardTitle(children=["Space"]) - if space_drawer == "default": - # draw with the default implementation - components_matplotlib.SpaceMatplotlib( - model, agent_portrayal, dependencies=dependencies - ) - elif space_drawer == "altair": - components_altair.SpaceAltair( - model, agent_portrayal, dependencies=dependencies - ) - elif space_drawer: - # if specified, draw agent space with an alternate renderer - space_drawer(model, agent_portrayal, dependencies=dependencies) - elif "Measure" in layout_type: - rv.CardTitle(children=["Measure"]) - measure = measures[layout_type["Measure"]] - if callable(measure): - # Is a custom object - measure(model) - else: - components_matplotlib.PlotMatplotlib( - model, measure, dependencies=dependencies - ) - return main - - -@solara.component -def SolaraViz( - model_class, - model_params, - measures=None, - name=None, - agent_portrayal=None, - space_drawer="default", - play_interval=150, - seed=None, -): - """Initialize a component to visualize a model. - - Args: - model_class: Class of the model to instantiate - model_params: Parameters for initializing the model - measures: List of callables or data attributes to plot - name: Name for display - agent_portrayal: Options for rendering agents (dictionary); - Default drawer supports custom `"size"`, `"color"`, and `"shape"`. - space_drawer: Method to render the agent space for - the model; default implementation is the `SpaceMatplotlib` component; - simulations with no space to visualize should - specify `space_drawer=False` - play_interval: Play interval (default: 150) - seed: The random seed used to initialize the model - """ - if name is None: - name = model_class.__name__ - - current_step = solara.use_reactive(0) - - # 1. Set up model parameters - reactive_seed = solara.use_reactive(0) - user_params, fixed_params = split_model_params(model_params) - model_parameters, set_model_parameters = solara.use_state( - {**fixed_params, **{k: v.get("value") for k, v in user_params.items()}} - ) - - # 2. Set up Model - def make_model(): - """Create a new model instance with current parameters and seed.""" - model = model_class.__new__( - model_class, **model_parameters, seed=reactive_seed.value - ) - model.__init__(**model_parameters) - current_step.value = 0 - return model - - reset_counter = solara.use_reactive(0) - model = solara.use_memo( - make_model, - dependencies=[ - *list(model_parameters.values()), - reset_counter.value, - reactive_seed.value, - ], - ) - - def handle_change_model_params(name: str, value: any): - """Update model parameters when user input changes.""" - set_model_parameters({**model_parameters, name: value}) - - # 3. Set up UI - - with solara.AppBar(): - solara.AppBarTitle(name) - - # render layout and plot - def do_reseed(): - """Update the random seed for the model.""" - reactive_seed.value = model.random.random() - - dependencies = [ - *list(model_parameters.values()), - current_step.value, - reactive_seed.value, - ] - - # if space drawer is disabled, do not include it - layout_types = [{"Space": "default"}] if space_drawer else [] - - if measures: - layout_types += [{"Measure": elem} for elem in range(len(measures))] - - grid_layout_initial = make_initial_grid_layout(layout_types=layout_types) - grid_layout, set_grid_layout = solara.use_state(grid_layout_initial) - - with solara.Sidebar(): - with solara.Card("Controls", margin=1, elevation=2): - solara.InputText( - label="Seed", - value=reactive_seed, - continuous_update=True, - ) - UserInputs(user_params, on_change=handle_change_model_params) - ModelController(model, play_interval, current_step, reset_counter) - solara.Button(label="Reseed", color="primary", on_click=do_reseed) - with solara.Card("Information", margin=1, elevation=2): - solara.Markdown(md_text=f"Step - {current_step}") - - items = [ - Card( - model, - measures, - agent_portrayal, - space_drawer, - dependencies, - color="white", - layout_type=layout_types[i], - ) - for i in range(len(layout_types)) - ] - solara.GridDraggable( - items=items, - grid_layout=grid_layout, - resizable=True, - draggable=True, - on_grid_layout=set_grid_layout, - ) - - -JupyterViz = SolaraViz - - -@solara.component -def ModelController(model, play_interval, current_step, reset_counter): - """Create controls for model execution (step, play, pause, reset). - - Args: - model: The model being visualized - play_interval: Interval between steps during play - current_step: Reactive value for the current step - reset_counter: Counter to trigger model reset - """ - playing = solara.use_reactive(False) - thread = solara.use_reactive(None) - # We track the previous step to detect if user resets the model via - # clicking the reset button or changing the parameters. If previous_step > - # current_step, it means a model reset happens while the simulation is - # still playing. - previous_step = solara.use_reactive(0) - - def on_value_play(change): - """Handle play/pause state changes.""" - if previous_step.value > current_step.value and current_step.value == 0: - # We add extra checks for current_step.value == 0, just to be sure. - # We automatically stop the playing if a model is reset. - playing.value = False - elif model.running: - do_step() - else: - playing.value = False - - def do_step(): - """Advance the model by one step.""" - model.step() - previous_step.value = current_step.value - current_step.value = model.steps - - def do_play(): - """Run the model continuously.""" - model.running = True - while model.running: - do_step() - - def threaded_do_play(): - """Start a new thread for continuous model execution.""" - if thread is not None and thread.is_alive(): - return - thread.value = threading.Thread(target=do_play) - thread.start() - - def do_pause(): - """Pause the model execution.""" - if (thread is None) or (not thread.is_alive()): - return - model.running = False - thread.join() - - def do_reset(): - """Reset the model.""" - reset_counter.value += 1 - - def do_set_playing(value): - """Set the playing state.""" - if current_step.value == 0: - # This means the model has been recreated, and the step resets to - # 0. We want to avoid triggering the playing.value = False in the - # on_value_play function. - previous_step.value = current_step.value - playing.set(value) - - with solara.Row(): - solara.Button(label="Step", color="primary", on_click=do_step) - # This style is necessary so that the play widget has almost the same - # height as typical Solara buttons. - solara.Style( - """ - .widget-play { - height: 35px; - } - .widget-play button { - color: white; - background-color: #1976D2; // Solara blue color - } - """ - ) - widgets.Play( - value=0, - interval=play_interval, - repeat=True, - show_repeat=False, - on_value=on_value_play, - playing=playing.value, - on_playing=do_set_playing, - ) - solara.Button(label="Reset", color="primary", on_click=do_reset) - # threaded_do_play is not used for now because it - # doesn't work in Google colab. We use - # ipywidgets.Play until it is fixed. The threading - # version is definite a much better implementation, - # if it works. - # solara.Button(label="▶", color="primary", on_click=viz.threaded_do_play) - # solara.Button(label="⏸︎", color="primary", on_click=viz.do_pause) - # solara.Button(label="Reset", color="primary", on_click=do_reset) - - -def split_model_params(model_params): - """Split model parameters into user-adjustable and fixed parameters. - - Args: - model_params: Dictionary of all model parameters - - Returns: - tuple: (user_adjustable_params, fixed_params) - """ - model_params_input = {} - model_params_fixed = {} - for k, v in model_params.items(): - if check_param_is_fixed(v): - model_params_fixed[k] = v - else: - model_params_input[k] = v - return model_params_input, model_params_fixed - - -def check_param_is_fixed(param): - """Check if a parameter is fixed (not user-adjustable). - - Args: - param: Parameter to check - - Returns: - bool: True if parameter is fixed, False otherwise - """ - if isinstance(param, Slider): - return False - if not isinstance(param, dict): - return True - if "type" not in param: - return True - - -@solara.component -def UserInputs(user_params, on_change=None): - """Initialize user inputs for configurable model parameters. - - Currently supports :class:`solara.SliderInt`, :class:`solara.SliderFloat`, - :class:`solara.Select`, and :class:`solara.Checkbox`. - - Args: - user_params: Dictionary with options for the input, including label, - min and max values, and other fields specific to the input type. - on_change: Function to be called with (name, value) when the value of an input changes. - """ - for name, options in user_params.items(): - - def change_handler(value, name=name): - on_change(name, value) - - if isinstance(options, Slider): - slider_class = ( - solara.SliderFloat if options.is_float_slider else solara.SliderInt - ) - slider_class( - options.label, - value=options.value, - on_value=change_handler, - min=options.min, - max=options.max, - step=options.step, - ) - continue - - # label for the input is "label" from options or name - label = options.get("label", name) - input_type = options.get("type") - if input_type == "SliderInt": - solara.SliderInt( - label, - value=options.get("value"), - on_value=change_handler, - min=options.get("min"), - max=options.get("max"), - step=options.get("step"), - ) - elif input_type == "SliderFloat": - solara.SliderFloat( - label, - value=options.get("value"), - on_value=change_handler, - min=options.get("min"), - max=options.get("max"), - step=options.get("step"), - ) - elif input_type == "Select": - solara.Select( - label, - value=options.get("value"), - on_value=change_handler, - values=options.get("values"), - ) - elif input_type == "Checkbox": - solara.Checkbox( - label=label, - on_value=change_handler, - value=options.get("value"), - ) - else: - raise ValueError(f"{input_type} is not a supported input type") - - -def make_text(renderer): - """Create a function that renders text using Markdown. - - Args: - renderer: Function that takes a model and returns a string - - Returns: - function: A function that renders the text as Markdown - """ - - def function(model): - solara.Markdown(renderer(model)) - - return function - - -def make_initial_grid_layout(layout_types): - """Create an initial grid layout for visualization components. - - Args: - layout_types: List of layout types (Space or Measure) - - Returns: - list: Initial grid layout configuration - """ - return [ - { - "i": i, - "w": 6, - "h": 10, - "moved": False, - "x": 6 * (i % 2), - "y": 16 * (i - i % 2), - } - for i in range(len(layout_types)) - ]