Repository: projectmesa/mesa
Pull request #2540 (2024-12-08 19:48:41)

Add Interactive Play Interval Control to Mesa Visualization
### Summary
This PR adds a slider control to dynamically adjust simulation speed in Mesa's visualization interface. #2497 

### Motive
- Fixed play interval was limiting user control
- Need flexible speed control for observing different phenomena

### Implementation
- Added play interval slider (1ms-500ms)
- Made interval reactive for real-time updates
- Updated controllers to use dynamic interval

### Usage Examples
The play interval slider appears automatically in the Controls panel of any Mesa visualization, no additional configuration needed.

- Before
![](https://github.com/user-attachments/assets/1dcf16ec-3662-4998-b425-b8f14c1bd545)
- After
![](https://github.com/user-attachments/assets/8cbd2dde-e7a7-4776-b36f-faebe153117c)

### Additional Notes
- Maintains backward compatibility
- Safe range limits prevent performance issues


github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]      | Run time [95% CI]       |
|-----------------|--------|-------------------------|-------------------------|
| BoltzmannWealth | small  | 🔵 +1.8% [+1.3%, +2.3%] | 🔵 +0.7% [+0.6%, +0.8%] |
| BoltzmannWealth | large  | 🔵 +1.2% [+0.9%, +1.6%] | 🔵 +0.3% [-0.2%, +0.7%] |
| Schelling       | small  | 🔵 +1.0% [+0.8%, +1.2%] | 🔵 +0.2% [+0.0%, +0.3%] |
| Schelling       | large  | 🔵 +1.2% [+1.0%, +1.5%] | 🔵 +0.0% [-0.4%, +0.5%] |
| WolfSheep       | small  | 🔵 +0.6% [+0.4%, +0.8%] | 🔵 +0.4% [+0.3%, +0.6%] |
| WolfSheep       | large  | 🔵 +0.5% [+0.2%, +0.7%] | 🔵 -0.4% [-1.7%, +0.8%] |
| BoidFlockers    | small  | 🔴 +4.5% [+4.0%, +5.0%] | 🔵 +1.7% [+0.8%, +2.7%] |
| BoidFlockers    | large  | 🔴 +4.1% [+3.7%, +4.7%] | 🔵 +1.2% [+0.5%, +2.2%] |

quaquel: I like this PR, but I defer to @Corvince's view on it since he has been doing most of the work on the visualization stuff. 

Patch:
diff --git a/docs/getting_started.md b/docs/getting_started.md
index b928599b..83b83eae 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -180,6 +180,16 @@ Mesa now uses a new browser-based visualization system called SolaraViz. This al
 
 > **Note:** SolaraViz is experimental and still in active development for Mesa 3.0. While we attempt to minimize them, there might be API breaking changes between Mesa 3.0 and 3.1. There won't be breaking changes between Mesa 3.0.x patch releases.
 
+> **Note:** SolaraViz instantiates new models using `**model_parameters.value`, so all model inputs must be keyword arguments.
+
+Ensure your model's `__init__` method accepts keyword arguments matching the `model_params` keys.
+
+```python
+class MyModel(Model):
+    def __init__(self, n_agents=10, seed=None):
+        super().__init__(seed=seed)
+        # Initialize the model with N agents
+
 The core functionality for building your own visualizations resides in the [`mesa.visualization`](apis/visualization) namespace
 
 Here's a basic example of how to set up a visualization:
diff --git a/docs/migration_guide.md b/docs/migration_guide.md
index 1422d272..28bd90c4 100644
--- a/docs/migration_guide.md
+++ b/docs/migration_guide.md
@@ -259,6 +259,17 @@ the import from mesa.experimental. Otherwise here is a list of things you need t
 
 Previously SolaraViz was initialized by providing a `model_cls` and a `model_params`. This has changed to expect a model instance `model`. You can still provide (user-settable) `model_params`, but only if users should be able to change them. It is now also possible to pass in a "reactive model" by first calling `model = solara.reactive(model)`. This is useful for notebook environments. It allows you to pass the model to the SolaraViz Module, but continue to use the model. For example calling `model.value.step()` (notice the extra .value) will automatically update the plots. This currently only automatically works for the step method, you can force visualization updates by calling `model.value.force_update()`.
 
+### Model Initialization with Keyword Arguments
+
+With the introduction of SolaraViz in Mesa 3.0, models are now instantiated using `**model_parameters.value`. This means all inputs for initializing a new model must be keyword arguments. Ensure your model's `__init__` method accepts keyword arguments matching the keys in `model_params`.
+
+```python
+class MyModel(mesa.Model):
+    def __init__(self, n_agents=10, seed=None):
+        super().__init__(seed=seed)
+        # Initialize the model with N agents
+```
+
 #### Default space visualization
 
 Previously we included a default space drawer that you could configure with an `agent_portrayal` function. You now have to explicitly create a space drawer with the `agent_portrayal` function
diff --git a/docs/tutorials/visualization_tutorial.ipynb b/docs/tutorials/visualization_tutorial.ipynb
index f28ece08..48794f15 100644
--- a/docs/tutorials/visualization_tutorial.ipynb
+++ b/docs/tutorials/visualization_tutorial.ipynb
@@ -3,7 +3,9 @@
   {
    "cell_type": "markdown",
    "metadata": {},
-   "source": "# Visualization Tutorial"
+   "source": [
+    "# Visualization Tutorial"
+   ]
   },
   {
    "cell_type": "markdown",
@@ -51,10 +53,10 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "import mesa\n",
     "print(f\"Mesa version: {mesa.__version__}\")\n",
@@ -66,10 +68,10 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "def agent_portrayal(agent):\n",
     "    return {\n",
@@ -79,15 +81,17 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "markdown",
-   "source": "In addition to the portrayal method, we instantiate the model parameters, some of which are modifiable by user inputs. In this case, the number of agents, N, is specified as a slider of integers."
+   "metadata": {},
+   "source": [
+    "In addition to the portrayal method, we instantiate the model parameters, some of which are modifiable by user inputs. In this case, the number of agents, N, is specified as a slider of integers."
+   ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "model_params = {\n",
     "    \"n\": {\n",
@@ -104,8 +108,8 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "Next, we instantiate the visualization object which (by default) displays the grid containing the agents, and timeseries of values computed by the model's data collector. In this example, we specify the Gini coefficient.\n",
     "\n",
@@ -118,13 +122,13 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# Create initial model instance\n",
-    "model1 = MoneyModel(50, 10, 10)\n",
+    "model1 = MoneyModel(n=50, width=10, height=10) #keyword arguments\n",
     "\n",
     "SpaceGraph = make_space_component(agent_portrayal)\n",
     "GiniPlot = make_plot_component(\"Gini\")\n",
@@ -140,8 +144,8 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "## Part 2 - Dynamic Agent Representation \n",
     "\n",
@@ -155,10 +159,10 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "import mesa\n",
     "print(f\"Mesa version: {mesa.__version__}\")\n",
@@ -169,10 +173,10 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "def agent_portrayal(agent):\n",
     "    size = 10\n",
@@ -197,13 +201,13 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# Create initial model instance\n",
-    "model1 = MoneyModel(50, 10, 10)\n",
+    "model = MoneyModel(n=50, width=10, height=10)\n",
     "\n",
     "SpaceGraph = make_space_component(agent_portrayal)\n",
     "GiniPlot = make_plot_component(\"Gini\")\n",
@@ -219,8 +223,8 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "## Part 3 - Custom Components \n",
     "\n",
@@ -236,10 +240,10 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "import mesa\n",
     "print(f\"Mesa version: {mesa.__version__}\")\n",
@@ -253,10 +257,10 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "def agent_portrayal(agent):\n",
     "    size = 10\n",
@@ -281,15 +285,17 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "markdown",
-   "source": "Next, we update our solara frontend to use this new component"
+   "metadata": {},
+   "source": [
+    "Next, we update our solara frontend to use this new component"
+   ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "@solara.component\n",
     "def Histogram(model):\n",
@@ -306,13 +312,13 @@
    ]
   },
   {
-   "metadata": {},
    "cell_type": "code",
-   "outputs": [],
    "execution_count": null,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "# Create initial model instance\n",
-    "model1 = MoneyModel(50, 10, 10)\n",
+    "model = MoneyModel(n=50, width=10, height=10)\n",
     "\n",
     "SpaceGraph = make_space_component(agent_portrayal)\n",
     "GiniPlot = make_plot_component(\"Gini\")"
@@ -320,42 +326,42 @@
   },
   {
    "cell_type": "code",
+   "execution_count": 12,
    "metadata": {
     "ExecuteTime": {
      "end_time": "2024-10-29T19:38:49.471838Z",
      "start_time": "2024-10-29T19:38:47.897295Z"
     }
    },
-   "source": [
-    "page = SolaraViz(\n",
-    "    model1,\n",
-    "    components=[SpaceGraph, GiniPlot, Histogram],\n",
-    "    model_params=model_params,\n",
-    "    name=\"Boltzmann Wealth Model\",\n",
-    ")\n",
-    "# This is required to render the visualization in the Jupyter notebook\n",
-    "page"
-   ],
    "outputs": [
     {
      "data": {
-      "text/plain": [
-       "Cannot show ipywidgets in text"
-      ],
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "bc71b89ee5684038a194eee4c36f4a4c",
+       "version_major": 2,
+       "version_minor": 0
+      },
       "text/html": [
        "Cannot show widget. You probably want to rerun the code cell above (<i>Click in the code cell, and press Shift+Enter <kbd>⇧</kbd>+<kbd>↩</kbd></i>)."
       ],
-      "application/vnd.jupyter.widget-view+json": {
-       "version_major": 2,
-       "version_minor": 0,
-       "model_id": "bc71b89ee5684038a194eee4c36f4a4c"
-      }
+      "text/plain": [
+       "Cannot show ipywidgets in text"
+      ]
      },
      "metadata": {},
      "output_type": "display_data"
     }
    ],
-   "execution_count": 12
+   "source": [
+    "page = SolaraViz(\n",
+    "    model,\n",
+    "    components=[SpaceGraph, GiniPlot, Histogram],\n",
+    "    model_params=model_params,\n",
+    "    name=\"Boltzmann Wealth Model\",\n",
+    ")\n",
+    "# This is required to render the visualization in the Jupyter notebook\n",
+    "page"
+   ]
   },
   {
    "cell_type": "markdown",
@@ -366,35 +372,35 @@
   },
   {
    "cell_type": "code",
+   "execution_count": 13,
    "metadata": {
     "ExecuteTime": {
      "end_time": "2024-10-29T19:38:49.505725Z",
      "start_time": "2024-10-29T19:38:49.472599Z"
     }
    },
-   "source": [
-    "Histogram(model1)"
-   ],
    "outputs": [
     {
      "data": {
-      "text/plain": [
-       "Cannot show ipywidgets in text"
-      ],
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "0491f167a1434a92b78535078bd082a8",
+       "version_major": 2,
+       "version_minor": 0
+      },
       "text/html": [
        "Cannot show widget. You probably want to rerun the code cell above (<i>Click in the code cell, and press Shift+Enter <kbd>⇧</kbd>+<kbd>↩</kbd></i>)."
       ],
-      "application/vnd.jupyter.widget-view+json": {
-       "version_major": 2,
-       "version_minor": 0,
-       "model_id": "0491f167a1434a92b78535078bd082a8"
-      }
+      "text/plain": [
+       "Cannot show ipywidgets in text"
+      ]
      },
      "metadata": {},
      "output_type": "display_data"
     }
    ],
-   "execution_count": 13
+   "source": [
+    "Histogram(model)"
+   ]
   },
   {
    "cell_type": "markdown",
diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py
index 294dab34..d01c66e5 100644
--- a/mesa/visualization/solara_viz.py
+++ b/mesa/visualization/solara_viz.py
@@ -102,24 +102,33 @@ def SolaraViz(
 
     # set up reactive model_parameters shared by ModelCreator and ModelController
     reactive_model_parameters = solara.use_reactive({})
+    reactive_play_interval = solara.use_reactive(play_interval)
 
     with solara.AppBar():
         solara.AppBarTitle(name if name else model.value.__class__.__name__)
 
     with solara.Sidebar(), solara.Column():
         with solara.Card("Controls"):
+            solara.SliderInt(
+                label="Play Interval (ms)",
+                value=reactive_play_interval,
+                on_value=lambda v: reactive_play_interval.set(v),
+                min=1,
+                max=500,
+                step=10,
+            )
             if not isinstance(simulator, Simulator):
                 ModelController(
                     model,
                     model_parameters=reactive_model_parameters,
-                    play_interval=play_interval,
+                    play_interval=reactive_play_interval,
                 )
             else:
                 SimulatorController(
                     model,
                     simulator,
                     model_parameters=reactive_model_parameters,
-                    play_interval=play_interval,
+                    play_interval=reactive_play_interval,
                 )
         with solara.Card("Model Parameters"):
             ModelCreator(
@@ -179,7 +188,7 @@ def ModelController(
     model: solara.Reactive[Model],
     *,
     model_parameters: dict | solara.Reactive[dict] = None,
-    play_interval: int = 100,
+    play_interval: int | solara.Reactive[int] = 100,
 ):
     """Create controls for model execution (step, play, pause, reset).
 
@@ -197,7 +206,7 @@ def ModelController(
 
     async def step():
         while playing.value and running.value:
-            await asyncio.sleep(play_interval / 1000)
+            await asyncio.sleep(play_interval.value / 1000)
             do_step()
 
     solara.lab.use_task(
@@ -249,7 +258,7 @@ def SimulatorController(
     simulator,
     *,
     model_parameters: dict | solara.Reactive[dict] = None,
-    play_interval: int = 100,
+    play_interval: int | solara.Reactive[int] = 100,
 ):
     """Create controls for model execution (step, play, pause, reset).
 
@@ -268,7 +277,7 @@ def SimulatorController(
 
     async def step():
         while playing.value and running.value:
-            await asyncio.sleep(play_interval / 1000)
+            await asyncio.sleep(play_interval.value / 1000)
             do_step()
 
     solara.lab.use_task(
@@ -423,6 +432,17 @@ def _check_model_params(init_func, model_params):
         ValueError: If a parameter is not valid for the model's initialization function
     """
     model_parameters = inspect.signature(init_func).parameters
+
+    has_var_positional = any(
+        param.kind == inspect.Parameter.VAR_POSITIONAL
+        for param in model_parameters.values()
+    )
+
+    if has_var_positional:
+        raise ValueError(
+            "Mesa's visualization requires the use of keyword arguments to ensure the parameters are passed to Solara correctly. Please ensure all model parameters are of form param=value"
+        )
+
     for name in model_parameters:
         if (
             model_parameters[name].default == inspect.Parameter.empty


Test patch:
diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py
index 67a72dcb..3b8d82fb 100644
--- a/tests/test_solara_viz.py
+++ b/tests/test_solara_viz.py
@@ -213,3 +213,20 @@ def test_model_param_checks():  # noqa: D103
     # Test empty params dict raises ValueError if required params
     with pytest.raises(ValueError, match="Missing required model parameter"):
         _check_model_params(ModelWithOnlyRequired.__init__, {})
+
+
+# test that _check_model_params raises ValueError when *args are present
+def test_check_model_params_with_args_only():
+    """Test that _check_model_params raises ValueError when *args are present."""
+
+    class ModelWithArgsOnly:
+        def __init__(self, param1, *args):
+            pass
+
+    model_params = {"param1": 1}
+
+    with pytest.raises(
+        ValueError,
+        match="Mesa's visualization requires the use of keyword arguments to ensure the parameters are passed to Solara correctly. Please ensure all model parameters are of form param=value",
+    ):
+        _check_model_params(ModelWithArgsOnly.__init__, model_params)