Repository: projectmesa/mesa

Issue:
SolaraViz: Add Slider controlling `play_interval` by default
Aside from the current buttons, having by default a Slider which controls the `play_interval` in SolaraViz would be very useful. It would allow controlling the speed directly from SolaraViz, not only on initialisation.

Discussion:
@wang-boyu: Hi @EwoutH would you like to close this? Or is there anything missing from https://github.com/projectmesa/mesa/pull/2540 that needs to be further developed@EwoutH: Yes, can indeed be closed!

Solution patch:
diff --git a/docs/getting_started.md b/docs/getting_started.md
index b928599b5f4..83b83eaee40 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 1422d272b47..28bd90c4a46 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 f28ece08308..48794f15542 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 294dab34ad4..d01c66e5f94 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 67a72dcba9b..3b8d82fb7bc 100644
--- a/tests/test_solara_viz.py
+++ b/tests/test_solara_viz.py
@@ -213,3 +213,20 @@ def __init__(self, **kwargs):
     # 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)