From 87e064e0e976e57d5b12496b4879a8449a1bce1b Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Fri, 8 Nov 2024 14:20:26 +0100 Subject: [PATCH 01/26] switch to devs based wolf-sheep --- mesa/examples/advanced/wolf_sheep/agents.py | 43 +++++++++++++-------- mesa/examples/advanced/wolf_sheep/model.py | 24 ++++++------ tests/test_examples.py | 10 +++-- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/mesa/examples/advanced/wolf_sheep/agents.py b/mesa/examples/advanced/wolf_sheep/agents.py index 8e71988bc9a..ace293c5a70 100644 --- a/mesa/examples/advanced/wolf_sheep/agents.py +++ b/mesa/examples/advanced/wolf_sheep/agents.py @@ -76,27 +76,38 @@ def feed(self): class GrassPatch(FixedAgent): - """ - A patch of grass that grows at a fixed rate and it is eaten by sheep - """ + """A patch of grass that grows at a fixed rate and it is eaten by sheep.""" - def __init__(self, model, fully_grown, countdown): - """ - Creates a new patch of grass + @property + def fully_grown(self): # noqa: D102 + return self._fully_grown + + @fully_grown.setter + def fully_grown(self, value: bool) -> None: + self._fully_grown = value + + if not value: + self.model.simulator.schedule_event_relative( + setattr, + self.grass_regrowth_time, + function_args=[self, "fully_grown", True], + ) + + def __init__(self, model, countdown, grass_regrowth_time, cell): + """Creates a new patch of grass. Args: - grown: (boolean) Whether the patch of grass is fully grown or not + model: a model instance countdown: Time for the patch of grass to be fully grown again + grass_regrowth_time : time to fully regrow grass + cell: the cell to which the patch of grass belongs """ super().__init__(model) - self.fully_grown = fully_grown - self.countdown = countdown + self._fully_grown = True if countdown == 0 else False # Noqa: SIM210 + self.grass_regrowth_time = grass_regrowth_time + self.cell = cell - def step(self): if not self.fully_grown: - if self.countdown <= 0: - # Set as fully grown - self.fully_grown = True - self.countdown = self.model.grass_regrowth_time - else: - self.countdown -= 1 + self.model.simulator.schedule_event_relative( + setattr, countdown, function_args=[self, "fully_grown", True] + ) diff --git a/mesa/examples/advanced/wolf_sheep/model.py b/mesa/examples/advanced/wolf_sheep/model.py index 2ee09d3f732..38cefea915d 100644 --- a/mesa/examples/advanced/wolf_sheep/model.py +++ b/mesa/examples/advanced/wolf_sheep/model.py @@ -40,6 +40,7 @@ class WolfSheep(mesa.Model): def __init__( self, + simulator, width=20, height=20, initial_sheep=100, @@ -56,6 +57,7 @@ def __init__( Create a new Wolf-Sheep model with the given parameters. Args: + simulator: a Simulator instance initial_sheep: Number of sheep to start with initial_wolves: Number of wolves to start with sheep_reproduce: Probability of each sheep reproducing each step @@ -67,6 +69,8 @@ def __init__( sheep_gain_from_food: Energy sheep gain from grass, if enabled. """ super().__init__(seed=seed) + self.simulator = simulator + # Set parameters self.width = width self.height = height @@ -107,16 +111,11 @@ def __init__( # Create grass patches if self.grass: - for cell in self.grid.all_cells: - fully_grown = self.random.choice([True, False]) - - if fully_grown: - countdown = self.grass_regrowth_time - else: - countdown = self.random.randrange(self.grass_regrowth_time) - - patch = GrassPatch(self, fully_grown, countdown) - patch.cell = cell + possibly_fully_grown = [True, False] + for cell in self.grid: + fully_grown = self.random.choice(possibly_fully_grown) + countdown = 0 if fully_grown else self.random.randrange(grass_regrowth_time) + GrassPatch(self, countdown, grass_regrowth_time, cell) self.running = True self.datacollector.collect(self) @@ -125,8 +124,9 @@ def step(self): # This replicated the behavior of the old RandomActivationByType scheduler # when using step(shuffle_types=True, shuffle_agents=True). # Conceptually, it can be argued that this should be modelled differently. - self.random.shuffle(self.agent_types) - for agent_type in self.agent_types: + agent_types = [Wolf, Sheep] + self.random.shuffle(agent_types) + for agent_type in agent_types: self.agents_by_type[agent_type].shuffle_do("step") # collect data diff --git a/tests/test_examples.py b/tests/test_examples.py index ff5cd478e06..00ddc64b581 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -10,7 +10,7 @@ VirusOnNetwork, WolfSheep, ) - +from mesa.experimental.devs import ABMSimulator def test_boltzmann_model(): # noqa: D103 model = BoltzmannWealthModel(seed=42) @@ -66,7 +66,9 @@ def test_sugarscape_g1mt(): # noqa: D103 def test_wolf_sheep(): # noqa: D103 - model = WolfSheep(seed=42) + simulator = ABMSimulator() + model = WolfSheep(simulator, seed=42) - for _i in range(10): - model.step() + simulator.setup(model) + + simulator.run_for(10) From 16d7b7b852ddff6cf41b847211fdf5e2776dd488 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Fri, 8 Nov 2024 19:52:50 +0100 Subject: [PATCH 02/26] bug fixes in devs --- mesa/experimental/devs/simulator.py | 29 ++++++++++++++++------------- tests/test_devs.py | 2 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index 8967c19ef8e..610f121c47b 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -58,7 +58,9 @@ def setup(self, model: Model) -> None: model (Model): The model to simulate """ - self.event_list.clear() + if self.time != self.start_time: + raise ValueError(f"something has gone terribly wrong {self.time} {self.start_time}") + self.model = model def reset(self): @@ -84,6 +86,16 @@ def run_until(self, end_time: int | float) -> None: self._schedule_event(event) # reschedule event break + def step(self): + """Execute the next event.""" + try: + event = self.event_list.pop_event() + except IndexError: # event list is empty + return + else: + self.time = event.time + event.execute() + def run_for(self, time_delta: int | float): """Run the simulator for the specified time delta. @@ -228,7 +240,7 @@ def setup(self, model): """ super().setup(model) - self.schedule_event_now(self.model.step, priority=Priority.HIGH) + self.schedule_event_next_tick(self.model.step, priority=Priority.HIGH) def check_time_unit(self, time) -> bool: """Check whether the time is of the correct unit. @@ -285,6 +297,8 @@ def run_until(self, end_time: int) -> None: self.time = end_time break + # fixme: the alternative would be to wrap model.step with an annotation which + # handles this scheduling. if event.time <= end_time: self.time = event.time if event.fn() == self.model.step: @@ -298,17 +312,6 @@ def run_until(self, end_time: int) -> None: self._schedule_event(event) break - def run_for(self, time_delta: int): - """Run the simulator for the specified time delta. - - Args: - time_delta (float| int): The time delta. The simulator is run from the current time to the current time - plus the time delta - - """ - end_time = self.time + time_delta - 1 - self.run_until(end_time) - class DEVSimulator(Simulator): """A simulator where the unit of time is a float. diff --git a/tests/test_devs.py b/tests/test_devs.py index 8f1dd9373fd..a482d2fed6a 100644 --- a/tests/test_devs.py +++ b/tests/test_devs.py @@ -86,7 +86,7 @@ def test_abm_simulator(): simulator.run_for(3) assert model.step.call_count == 3 - assert simulator.time == 2 + assert simulator.time == 3 def test_simulation_event(): From 2f9928e96e414ef8c04ff5cee300a6756deccc3f Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Fri, 8 Nov 2024 19:59:08 +0100 Subject: [PATCH 03/26] add simulator controller --- mesa/visualization/solara_viz.py | 84 ++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 023d449faf2..824ae3d9457 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from mesa.model import Model + from mesa.experimental.devs.simulator import Simulator @solara.component @@ -46,6 +47,7 @@ def SolaraViz( | list[Callable[[Model], reacton.core.Component]] | Literal["default"] = "default", play_interval: int = 100, + simulator: Simulator | None = None, model_params=None, name: str | None = None, ): @@ -115,11 +117,19 @@ def step(): with solara.Sidebar(), solara.Column(): with solara.Card("Controls"): - ModelController( - model, - model_parameters=reactive_model_parameters, - play_interval=play_interval, - ) + if simulator is None: + ModelController( + model, + model_parameters=reactive_model_parameters, + play_interval=play_interval, + ) + else: + SimulatorController( + model, + simulator, + model_parameters=reactive_model_parameters, + play_interval=play_interval, + ) with solara.Card("Model Parameters"): ModelCreator( model, model_params, model_parameters=reactive_model_parameters @@ -234,6 +244,70 @@ def do_play_pause(): ) +@solara.component +def SimulatorController( + model: solara.Reactive[Model], + simulator, + *, + model_parameters: dict | solara.Reactive[dict] = None, + play_interval: int = 100, +): + """Create controls for model execution (step, play, pause, reset). + + Args: + model: Reactive model instance + simulator: Simulator instance + model_parameters: Reactive parameters for (re-)instantiating a model. + play_interval: Interval for playing the model steps in milliseconds. + + """ + playing = solara.use_reactive(False) + running = solara.use_reactive(True) + if model_parameters is None: + model_parameters = {} + model_parameters = solara.use_reactive(model_parameters) + + async def step(): + while playing.value and running.value: + await asyncio.sleep(play_interval / 1000) + do_step() + + solara.lab.use_task( + step, dependencies=[playing.value, running.value], prefer_threaded=False + ) + + def do_step(): + """Advance the model by one step.""" + simulator.run_for(1) # fixme + running.value = model.value.running + + def do_reset(): + """Reset the model to its initial state.""" + playing.value = False + running.value = True + simulator.reset() + model.value = model.value = model.value.__class__(simulator, **model_parameters.value) + simulator.setup(model.value) + + def do_play_pause(): + """Toggle play/pause.""" + playing.value = not playing.value + + with solara.Row(justify="space-between"): + solara.Button(label="Reset", color="primary", on_click=do_reset) + solara.Button( + label="▶" if not playing.value else "❚❚", + color="primary", + on_click=do_play_pause, + disabled=not running.value, + ) + solara.Button( + label="Step", + color="primary", + on_click=do_step, + disabled=playing.value or not running.value, + ) + def split_model_params(model_params): """Split model parameters into user-adjustable and fixed parameters. From 83e9f3cc95f69f95ccfdd7c29d35b89ff34eb58f Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Fri, 8 Nov 2024 19:59:55 +0100 Subject: [PATCH 04/26] Update wolf_sheep.py --- benchmarks/WolfSheep/wolf_sheep.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py index f085ce429df..ef315a4c2c1 100644 --- a/benchmarks/WolfSheep/wolf_sheep.py +++ b/benchmarks/WolfSheep/wolf_sheep.py @@ -199,7 +199,7 @@ def __init__( possibly_fully_grown = [True, False] for cell in self.grid: fully_grown = self.random.choice(possibly_fully_grown) - countdown = 0 if fully_grown else self.random.randrange(grass_regrowth_time) + countdown = 0 if fully_grown else self.random.randrange(0, grass_regrowth_time) GrassPatch(self, countdown, grass_regrowth_time, cell) def step(self): From 1cebacf6512927ae70415a46ca24d537727ad5a5 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Fri, 8 Nov 2024 20:00:02 +0100 Subject: [PATCH 05/26] testing --- mesa/examples/advanced/wolf_sheep/app.py | 29 +++++++++++++++---- mesa/examples/advanced/wolf_sheep/model.py | 11 +++---- .../basic/boltzmann_wealth_model/app.py | 5 ++++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/mesa/examples/advanced/wolf_sheep/app.py b/mesa/examples/advanced/wolf_sheep/app.py index 94261021b6a..3d78be6fb38 100644 --- a/mesa/examples/advanced/wolf_sheep/app.py +++ b/mesa/examples/advanced/wolf_sheep/app.py @@ -1,3 +1,10 @@ +import sys +import os.path as osp + +sys.path.insert(0, osp.abspath("../../../..")) + + + from mesa.examples.advanced.wolf_sheep.agents import GrassPatch, Sheep, Wolf from mesa.examples.advanced.wolf_sheep.model import WolfSheep from mesa.visualization import ( @@ -7,6 +14,7 @@ make_space_component, ) +from mesa.experimental.devs import ABMSimulator def wolf_sheep_portrayal(agent): if agent is None: @@ -37,6 +45,11 @@ def wolf_sheep_portrayal(agent): model_params = { # The following line is an example to showcase StaticText. + "seed": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, "grass": { "type": "Select", "value": True, @@ -59,26 +72,32 @@ def wolf_sheep_portrayal(agent): } -def post_process(ax): +def post_process_space(ax): ax.set_aspect("equal") ax.set_xticks([]) ax.set_yticks([]) +def post_process_lines(ax): + ax.legend(loc="center left", bbox_to_anchor=(1, 0.9)) + space_component = make_space_component( - wolf_sheep_portrayal, draw_grid=False, post_process=post_process + wolf_sheep_portrayal, draw_grid=False, post_process=post_process_space ) lineplot_component = make_plot_component( - {"Wolves": "tab:orange", "Sheep": "tab:cyan", "Grass": "tab:green"} + {"Wolves": "tab:orange", "Sheep": "tab:cyan", "Grass": "tab:green"}, + post_process=post_process_lines ) -model = WolfSheep(grass=True) - +simulator = ABMSimulator() +model = WolfSheep(simulator, grass=True) +simulator.run_for(1) page = SolaraViz( model, components=[space_component, lineplot_component], model_params=model_params, name="Wolf Sheep", + simulator=simulator ) page # noqa diff --git a/mesa/examples/advanced/wolf_sheep/model.py b/mesa/examples/advanced/wolf_sheep/model.py index 38cefea915d..14dee5c73a3 100644 --- a/mesa/examples/advanced/wolf_sheep/model.py +++ b/mesa/examples/advanced/wolf_sheep/model.py @@ -40,7 +40,7 @@ class WolfSheep(mesa.Model): def __init__( self, - simulator, + simulator=None, width=20, height=20, initial_sheep=100, @@ -70,6 +70,7 @@ def __init__( """ super().__init__(seed=seed) self.simulator = simulator + self.simulator.setup(self) # Set parameters self.width = width @@ -79,7 +80,7 @@ def __init__( self.grass = grass self.grass_regrowth_time = grass_regrowth_time - self.grid = OrthogonalMooreGrid((self.width, self.height), torus=True) + self.grid = OrthogonalMooreGrid((self.width, self.height), torus=True, random=self.random) collectors = { "Wolves": lambda m: len(m.agents_by_type[Wolf]), @@ -114,7 +115,7 @@ def __init__( possibly_fully_grown = [True, False] for cell in self.grid: fully_grown = self.random.choice(possibly_fully_grown) - countdown = 0 if fully_grown else self.random.randrange(grass_regrowth_time) + countdown = 0 if fully_grown else self.random.randrange(0, stop=grass_regrowth_time) GrassPatch(self, countdown, grass_regrowth_time, cell) self.running = True @@ -131,7 +132,3 @@ def step(self): # collect data self.datacollector.collect(self) - - def run_model(self, step_count=200): - for _ in range(step_count): - self.step() diff --git a/mesa/examples/basic/boltzmann_wealth_model/app.py b/mesa/examples/basic/boltzmann_wealth_model/app.py index 15663f69036..0cdbbee3d94 100644 --- a/mesa/examples/basic/boltzmann_wealth_model/app.py +++ b/mesa/examples/basic/boltzmann_wealth_model/app.py @@ -1,3 +1,8 @@ +import sys +import os.path as osp + +sys.path.insert(0, osp.abspath("../../../..")) + from mesa.examples.basic.boltzmann_wealth_model.model import BoltzmannWealthModel from mesa.visualization import ( SolaraViz, From e49aa0b84050485e754623d9fcafdde906895e01 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 8 Nov 2024 19:09:30 +0000 Subject: [PATCH 06/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- benchmarks/WolfSheep/wolf_sheep.py | 4 +++- mesa/examples/advanced/wolf_sheep/agents.py | 2 +- mesa/examples/advanced/wolf_sheep/app.py | 10 +++++----- mesa/examples/advanced/wolf_sheep/model.py | 10 ++++++++-- mesa/examples/basic/boltzmann_wealth_model/app.py | 2 +- mesa/experimental/devs/simulator.py | 4 +++- mesa/visualization/solara_viz.py | 7 +++++-- tests/test_examples.py | 1 + 8 files changed, 27 insertions(+), 13 deletions(-) diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py index ef315a4c2c1..b3a168a2055 100644 --- a/benchmarks/WolfSheep/wolf_sheep.py +++ b/benchmarks/WolfSheep/wolf_sheep.py @@ -199,7 +199,9 @@ def __init__( possibly_fully_grown = [True, False] for cell in self.grid: fully_grown = self.random.choice(possibly_fully_grown) - countdown = 0 if fully_grown else self.random.randrange(0, grass_regrowth_time) + countdown = ( + 0 if fully_grown else self.random.randrange(0, grass_regrowth_time) + ) GrassPatch(self, countdown, grass_regrowth_time, cell) def step(self): diff --git a/mesa/examples/advanced/wolf_sheep/agents.py b/mesa/examples/advanced/wolf_sheep/agents.py index ace293c5a70..d5da8020c92 100644 --- a/mesa/examples/advanced/wolf_sheep/agents.py +++ b/mesa/examples/advanced/wolf_sheep/agents.py @@ -79,7 +79,7 @@ class GrassPatch(FixedAgent): """A patch of grass that grows at a fixed rate and it is eaten by sheep.""" @property - def fully_grown(self): # noqa: D102 + def fully_grown(self): return self._fully_grown @fully_grown.setter diff --git a/mesa/examples/advanced/wolf_sheep/app.py b/mesa/examples/advanced/wolf_sheep/app.py index 3d78be6fb38..a840f884f19 100644 --- a/mesa/examples/advanced/wolf_sheep/app.py +++ b/mesa/examples/advanced/wolf_sheep/app.py @@ -1,12 +1,12 @@ -import sys import os.path as osp +import sys sys.path.insert(0, osp.abspath("../../../..")) - from mesa.examples.advanced.wolf_sheep.agents import GrassPatch, Sheep, Wolf from mesa.examples.advanced.wolf_sheep.model import WolfSheep +from mesa.experimental.devs import ABMSimulator from mesa.visualization import ( Slider, SolaraViz, @@ -14,7 +14,6 @@ make_space_component, ) -from mesa.experimental.devs import ABMSimulator def wolf_sheep_portrayal(agent): if agent is None: @@ -77,6 +76,7 @@ def post_process_space(ax): ax.set_xticks([]) ax.set_yticks([]) + def post_process_lines(ax): ax.legend(loc="center left", bbox_to_anchor=(1, 0.9)) @@ -86,7 +86,7 @@ def post_process_lines(ax): ) lineplot_component = make_plot_component( {"Wolves": "tab:orange", "Sheep": "tab:cyan", "Grass": "tab:green"}, - post_process=post_process_lines + post_process=post_process_lines, ) simulator = ABMSimulator() @@ -98,6 +98,6 @@ def post_process_lines(ax): components=[space_component, lineplot_component], model_params=model_params, name="Wolf Sheep", - simulator=simulator + simulator=simulator, ) page # noqa diff --git a/mesa/examples/advanced/wolf_sheep/model.py b/mesa/examples/advanced/wolf_sheep/model.py index 14dee5c73a3..4bdb0e4cab1 100644 --- a/mesa/examples/advanced/wolf_sheep/model.py +++ b/mesa/examples/advanced/wolf_sheep/model.py @@ -80,7 +80,9 @@ def __init__( self.grass = grass self.grass_regrowth_time = grass_regrowth_time - self.grid = OrthogonalMooreGrid((self.width, self.height), torus=True, random=self.random) + self.grid = OrthogonalMooreGrid( + (self.width, self.height), torus=True, random=self.random + ) collectors = { "Wolves": lambda m: len(m.agents_by_type[Wolf]), @@ -115,7 +117,11 @@ def __init__( possibly_fully_grown = [True, False] for cell in self.grid: fully_grown = self.random.choice(possibly_fully_grown) - countdown = 0 if fully_grown else self.random.randrange(0, stop=grass_regrowth_time) + countdown = ( + 0 + if fully_grown + else self.random.randrange(0, stop=grass_regrowth_time) + ) GrassPatch(self, countdown, grass_regrowth_time, cell) self.running = True diff --git a/mesa/examples/basic/boltzmann_wealth_model/app.py b/mesa/examples/basic/boltzmann_wealth_model/app.py index 0cdbbee3d94..a1e4377f8e9 100644 --- a/mesa/examples/basic/boltzmann_wealth_model/app.py +++ b/mesa/examples/basic/boltzmann_wealth_model/app.py @@ -1,5 +1,5 @@ -import sys import os.path as osp +import sys sys.path.insert(0, osp.abspath("../../../..")) diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index 610f121c47b..c656654d225 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -59,7 +59,9 @@ def setup(self, model: Model) -> None: """ if self.time != self.start_time: - raise ValueError(f"something has gone terribly wrong {self.time} {self.start_time}") + raise ValueError( + f"something has gone terribly wrong {self.time} {self.start_time}" + ) self.model = model diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 824ae3d9457..1a7f4671f1c 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -36,8 +36,8 @@ from mesa.visualization.utils import force_update, update_counter if TYPE_CHECKING: - from mesa.model import Model from mesa.experimental.devs.simulator import Simulator + from mesa.model import Model @solara.component @@ -286,7 +286,9 @@ def do_reset(): playing.value = False running.value = True simulator.reset() - model.value = model.value = model.value.__class__(simulator, **model_parameters.value) + model.value = model.value = model.value.__class__( + simulator, **model_parameters.value + ) simulator.setup(model.value) def do_play_pause(): @@ -308,6 +310,7 @@ def do_play_pause(): disabled=playing.value or not running.value, ) + def split_model_params(model_params): """Split model parameters into user-adjustable and fixed parameters. diff --git a/tests/test_examples.py b/tests/test_examples.py index 00ddc64b581..979ae934608 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -12,6 +12,7 @@ ) from mesa.experimental.devs import ABMSimulator + def test_boltzmann_model(): # noqa: D103 model = BoltzmannWealthModel(seed=42) From 02e5eb4935ae59972af2b1d4952d96cab0b7e6ab Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Fri, 8 Nov 2024 21:36:34 +0100 Subject: [PATCH 07/26] docstring --- mesa/visualization/solara_viz.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 1a7f4671f1c..311a84607f8 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -67,6 +67,7 @@ def SolaraViz( Defaults to "default", which uses the default Altair space visualization. play_interval (int, optional): Interval for playing the model steps in milliseconds. This controls the speed of the model's automatic stepping. Defaults to 100 ms. + simulator: A simulator that controls the model (optional) model_params (dict, optional): Parameters for (re-)instantiating a model. Can include user-adjustable parameters and fixed parameters. Defaults to None. name (str | None, optional): Name of the visualization. Defaults to the models class name. From d32048b7403dfd9b8bddea73013e3f32e9787015 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 12:13:16 +0100 Subject: [PATCH 08/26] ongoing --- mesa/examples/advanced/wolf_sheep/app.py | 1 + mesa/examples/advanced/wolf_sheep/model.py | 1 - mesa/experimental/devs/simulator.py | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mesa/examples/advanced/wolf_sheep/app.py b/mesa/examples/advanced/wolf_sheep/app.py index a840f884f19..73e46bd0bab 100644 --- a/mesa/examples/advanced/wolf_sheep/app.py +++ b/mesa/examples/advanced/wolf_sheep/app.py @@ -91,6 +91,7 @@ def post_process_lines(ax): simulator = ABMSimulator() model = WolfSheep(simulator, grass=True) +simulator.setup(model) simulator.run_for(1) page = SolaraViz( diff --git a/mesa/examples/advanced/wolf_sheep/model.py b/mesa/examples/advanced/wolf_sheep/model.py index 4bdb0e4cab1..1265cc66a81 100644 --- a/mesa/examples/advanced/wolf_sheep/model.py +++ b/mesa/examples/advanced/wolf_sheep/model.py @@ -70,7 +70,6 @@ def __init__( """ super().__init__(seed=seed) self.simulator = simulator - self.simulator.setup(self) # Set parameters self.width = width diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index c656654d225..d12c1894395 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -106,6 +106,7 @@ def run_for(self, time_delta: int | float): plus the time delta """ + # fixme, raise initialization error or something like it if model.setup has not been called end_time = self.time + time_delta self.run_until(end_time) From d15be56c2acd4f17e3f083007cf7c22249b7c255 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 14:01:00 +0100 Subject: [PATCH 09/26] remove monekypatch of step and move force_update into do_step --- mesa/visualization/solara_viz.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 311a84607f8..6fa7fca90c9 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -95,20 +95,20 @@ def SolaraViz( if not isinstance(model, solara.Reactive): model = solara.use_reactive(model) # noqa: SH102, RUF100 - def connect_to_model(): - # Patch the step function to force updates - original_step = model.value.step - - def step(): - original_step() - force_update() - - model.value.step = step + # def connect_to_model(): + # # Patch the step function to force updates + # original_step = model.value.step + # + # def step(): + # original_step() + # force_update() + # + # model.value.step = step # Add a trigger to model itself - model.value.force_update = force_update - force_update() + # model.value.force_update = force_update + # force_update() - solara.use_effect(connect_to_model, [model.value]) + # solara.use_effect(connect_to_model, [model.value]) # set up reactive model_parameters shared by ModelCreator and ModelController reactive_model_parameters = solara.use_reactive({}) @@ -218,6 +218,7 @@ def do_step(): """Advance the model by one step.""" model.value.step() running.value = model.value.running + force_update() def do_reset(): """Reset the model to its initial state.""" @@ -281,6 +282,7 @@ def do_step(): """Advance the model by one step.""" simulator.run_for(1) # fixme running.value = model.value.running + force_update() def do_reset(): """Reset the model to its initial state.""" From 465691ba57cea5f951894be09bb3a16dcd098b9d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:01:10 +0000 Subject: [PATCH 10/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/visualization/solara_viz.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 6fa7fca90c9..56c7a7377f6 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -96,17 +96,17 @@ def SolaraViz( model = solara.use_reactive(model) # noqa: SH102, RUF100 # def connect_to_model(): - # # Patch the step function to force updates - # original_step = model.value.step - # - # def step(): - # original_step() - # force_update() - # - # model.value.step = step - # Add a trigger to model itself - # model.value.force_update = force_update - # force_update() + # # Patch the step function to force updates + # original_step = model.value.step + # + # def step(): + # original_step() + # force_update() + # + # model.value.step = step + # Add a trigger to model itself + # model.value.force_update = force_update + # force_update() # solara.use_effect(connect_to_model, [model.value]) From d8d59f1a10af4c55e535160c6f25bc06c9484277 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 19:46:00 +0100 Subject: [PATCH 11/26] devs related updates --- mesa/experimental/devs/simulator.py | 39 ++++++++++++++++++++++---- tests/test_devs.py | 43 ++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index d12c1894395..0cdb26713d0 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -57,11 +57,15 @@ def setup(self, model: Model) -> None: Args: model (Model): The model to simulate + Raises: + Exception if simulator.time is not equal to simulator.starttime + Exception if event list is not empty + """ if self.time != self.start_time: - raise ValueError( - f"something has gone terribly wrong {self.time} {self.start_time}" - ) + raise ValueError("trying to setup model, but current time is not equal to start_time, Has the simulator been reset or freshly initialized?") + if not self.event_list.is_empty(): + raise ValueError("trying to setup model, but events have already been scheduled. Call simulator.setup before any scheduling") self.model = model @@ -72,7 +76,18 @@ def reset(self): self.time = self.start_time def run_until(self, end_time: int | float) -> None: - """Run the simulator until the end time.""" + """Run the simulator until the end time. + + Args + end_time (int | float): The end time for stopping the simulator + + Raises: + Exception if simulator.setup() has not yet been called + + """ + if self.model is None: + raise Exception("simulator has not been setup, call simulator.setup(model) first") + while True: try: event = self.event_list.pop_event() @@ -89,7 +104,15 @@ def run_until(self, end_time: int | float) -> None: break def step(self): - """Execute the next event.""" + """Execute the next event. + + Raises: + Exception if simulator.setup() has not yet been called + + """ + if self.model is None: + raise Exception("simulator has not been setup, call simulator.setup(model) first") + try: event = self.event_list.pop_event() except IndexError: # event list is empty @@ -292,7 +315,13 @@ def run_until(self, end_time: int) -> None: Args: end_time (float| int): The end_time delta. The simulator is until the specified end time + Raises: + Exception if simulator.setup() has not yet been called + """ + if self.model is None: + raise Exception("simulator has not been setup, call simulator.setup(model) first") + while True: try: event = self.event_list.pop_event() diff --git a/tests/test_devs.py b/tests/test_devs.py index a482d2fed6a..29a9ec6529f 100644 --- a/tests/test_devs.py +++ b/tests/test_devs.py @@ -1,6 +1,6 @@ """Tests for experimental Simulator classes.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import pytest @@ -55,6 +55,23 @@ def test_devs_simulator(): with pytest.raises(ValueError): simulator.schedule_event_absolute(fn2, 0.5) + # step + simulator = DEVSimulator() + model = MagicMock(spec=Model) + simulator.setup(model) + + fn = MagicMock() + simulator.schedule_event_absolute(fn, 1.0) + simulator.step() + fn.assert_called_once() + assert simulator.time == 1.0 + simulator.step() + assert simulator.time == 1.0 + + simulator = DEVSimulator() + with pytest.raises(Exception): + simulator.step() + # cancel_event simulator = DEVSimulator() model = MagicMock(spec=Model) @@ -70,6 +87,25 @@ def test_devs_simulator(): assert simulator.model is None assert simulator.time == 0.0 + # run without setup + simulator = DEVSimulator() + with pytest.raises(Exception): + simulator.run_until(10) + + # setup with time advanced + simulator = DEVSimulator() + simulator.time = simulator.start_time+1 + model = MagicMock(spec=Model) + with pytest.raises(Exception): + simulator.setup(model) + + # setup with event scheduled + simulator = DEVSimulator() + simulator.schedule_event_now(Mock()) + with pytest.raises(Exception): + simulator.setup(model) + + def test_abm_simulator(): """Tests abm simulator.""" @@ -88,6 +124,11 @@ def test_abm_simulator(): assert model.step.call_count == 3 assert simulator.time == 3 + # run without setup + simulator = ABMSimulator() + with pytest.raises(Exception): + simulator.run_until(10) + def test_simulation_event(): """Tests for SimulationEvent class.""" From 3c1ac7a5dba8390b93f1cea10bf3033bdc5078a1 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 19:46:21 +0100 Subject: [PATCH 12/26] updates move setup into model --- mesa/examples/advanced/wolf_sheep/app.py | 2 -- mesa/examples/advanced/wolf_sheep/model.py | 1 + mesa/visualization/solara_viz.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/mesa/examples/advanced/wolf_sheep/app.py b/mesa/examples/advanced/wolf_sheep/app.py index 73e46bd0bab..64dd4cfa496 100644 --- a/mesa/examples/advanced/wolf_sheep/app.py +++ b/mesa/examples/advanced/wolf_sheep/app.py @@ -91,8 +91,6 @@ def post_process_lines(ax): simulator = ABMSimulator() model = WolfSheep(simulator, grass=True) -simulator.setup(model) -simulator.run_for(1) page = SolaraViz( model, diff --git a/mesa/examples/advanced/wolf_sheep/model.py b/mesa/examples/advanced/wolf_sheep/model.py index 1265cc66a81..4bdb0e4cab1 100644 --- a/mesa/examples/advanced/wolf_sheep/model.py +++ b/mesa/examples/advanced/wolf_sheep/model.py @@ -70,6 +70,7 @@ def __init__( """ super().__init__(seed=seed) self.simulator = simulator + self.simulator.setup(self) # Set parameters self.width = width diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 56c7a7377f6..6712e3b3ae0 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -292,7 +292,6 @@ def do_reset(): model.value = model.value = model.value.__class__( simulator, **model_parameters.value ) - simulator.setup(model.value) def do_play_pause(): """Toggle play/pause.""" From 8053c0e19e8339560bf2de488a52af466bb6ff17 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:46:29 +0000 Subject: [PATCH 13/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/experimental/devs/simulator.py | 22 ++++++++++++++++------ tests/test_devs.py | 3 +-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index 0cdb26713d0..9445e189b6c 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -63,9 +63,13 @@ def setup(self, model: Model) -> None: """ if self.time != self.start_time: - raise ValueError("trying to setup model, but current time is not equal to start_time, Has the simulator been reset or freshly initialized?") + raise ValueError( + "trying to setup model, but current time is not equal to start_time, Has the simulator been reset or freshly initialized?" + ) if not self.event_list.is_empty(): - raise ValueError("trying to setup model, but events have already been scheduled. Call simulator.setup before any scheduling") + raise ValueError( + "trying to setup model, but events have already been scheduled. Call simulator.setup before any scheduling" + ) self.model = model @@ -78,7 +82,7 @@ def reset(self): def run_until(self, end_time: int | float) -> None: """Run the simulator until the end time. - Args + Args: end_time (int | float): The end time for stopping the simulator Raises: @@ -86,7 +90,9 @@ def run_until(self, end_time: int | float) -> None: """ if self.model is None: - raise Exception("simulator has not been setup, call simulator.setup(model) first") + raise Exception( + "simulator has not been setup, call simulator.setup(model) first" + ) while True: try: @@ -111,7 +117,9 @@ def step(self): """ if self.model is None: - raise Exception("simulator has not been setup, call simulator.setup(model) first") + raise Exception( + "simulator has not been setup, call simulator.setup(model) first" + ) try: event = self.event_list.pop_event() @@ -320,7 +328,9 @@ def run_until(self, end_time: int) -> None: """ if self.model is None: - raise Exception("simulator has not been setup, call simulator.setup(model) first") + raise Exception( + "simulator has not been setup, call simulator.setup(model) first" + ) while True: try: diff --git a/tests/test_devs.py b/tests/test_devs.py index 29a9ec6529f..5788235a940 100644 --- a/tests/test_devs.py +++ b/tests/test_devs.py @@ -94,7 +94,7 @@ def test_devs_simulator(): # setup with time advanced simulator = DEVSimulator() - simulator.time = simulator.start_time+1 + simulator.time = simulator.start_time + 1 model = MagicMock(spec=Model) with pytest.raises(Exception): simulator.setup(model) @@ -106,7 +106,6 @@ def test_devs_simulator(): simulator.setup(model) - def test_abm_simulator(): """Tests abm simulator.""" simulator = ABMSimulator() From 6f37e6721979db13d1dfaf8647f30aec635fd472 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 19:47:56 +0100 Subject: [PATCH 14/26] cleanup --- mesa/examples/advanced/wolf_sheep/app.py | 7 ------- mesa/examples/basic/boltzmann_wealth_model/app.py | 5 ----- 2 files changed, 12 deletions(-) diff --git a/mesa/examples/advanced/wolf_sheep/app.py b/mesa/examples/advanced/wolf_sheep/app.py index 64dd4cfa496..fa66d2bf11e 100644 --- a/mesa/examples/advanced/wolf_sheep/app.py +++ b/mesa/examples/advanced/wolf_sheep/app.py @@ -1,9 +1,3 @@ -import os.path as osp -import sys - -sys.path.insert(0, osp.abspath("../../../..")) - - from mesa.examples.advanced.wolf_sheep.agents import GrassPatch, Sheep, Wolf from mesa.examples.advanced.wolf_sheep.model import WolfSheep from mesa.experimental.devs import ABMSimulator @@ -43,7 +37,6 @@ def wolf_sheep_portrayal(agent): model_params = { - # The following line is an example to showcase StaticText. "seed": { "type": "InputText", "value": 42, diff --git a/mesa/examples/basic/boltzmann_wealth_model/app.py b/mesa/examples/basic/boltzmann_wealth_model/app.py index a1e4377f8e9..15663f69036 100644 --- a/mesa/examples/basic/boltzmann_wealth_model/app.py +++ b/mesa/examples/basic/boltzmann_wealth_model/app.py @@ -1,8 +1,3 @@ -import os.path as osp -import sys - -sys.path.insert(0, osp.abspath("../../../..")) - from mesa.examples.basic.boltzmann_wealth_model.model import BoltzmannWealthModel from mesa.visualization import ( SolaraViz, From 10dfb5eec3510c86be8bd4cb877ccf161216965d Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 19:56:09 +0100 Subject: [PATCH 15/26] Update test_examples.py --- tests/test_examples.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 979ae934608..dec0696de23 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -68,8 +68,5 @@ def test_sugarscape_g1mt(): # noqa: D103 def test_wolf_sheep(): # noqa: D103 simulator = ABMSimulator() - model = WolfSheep(simulator, seed=42) - - simulator.setup(model) - + WolfSheep(simulator, seed=42) simulator.run_for(10) From 2d09cccd5859cb843785812d930d7a05879537f3 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 19:57:51 +0100 Subject: [PATCH 16/26] update benchmarks to reflect new devs usage --- benchmarks/Schelling/schelling.py | 1 + benchmarks/WolfSheep/wolf_sheep.py | 4 +--- benchmarks/global_benchmark.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py index f4543cb4312..733003221c5 100644 --- a/benchmarks/Schelling/schelling.py +++ b/benchmarks/Schelling/schelling.py @@ -75,6 +75,7 @@ def __init__( """ super().__init__(seed=seed) self.simulator = simulator + self.simulator.setup() self.happy = 0 self.grid = OrthogonalMooreGrid( diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py index b3a168a2055..99e44a00f58 100644 --- a/benchmarks/WolfSheep/wolf_sheep.py +++ b/benchmarks/WolfSheep/wolf_sheep.py @@ -166,6 +166,7 @@ def __init__( self.height = height self.width = width self.simulator = simulator + self.simulator.setup() self.initial_sheep = initial_sheep self.initial_wolves = initial_wolves @@ -225,9 +226,6 @@ def step(self): 20, seed=15, ) - - simulator.setup(model) - start_time = time.perf_counter() simulator.run(100) print("Time:", time.perf_counter() - start_time) diff --git a/benchmarks/global_benchmark.py b/benchmarks/global_benchmark.py index 41c2643f88c..e540048a00e 100644 --- a/benchmarks/global_benchmark.py +++ b/benchmarks/global_benchmark.py @@ -35,7 +35,6 @@ def run_model(model_class, seed, parameters): else: simulator = ABMSimulator() model = model_class(simulator=simulator, seed=seed, **parameters) - simulator.setup(model) end_init_start_run = timeit.default_timer() From 5b68291cce87bc875c832a947d361d0569f9d326 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 19:59:45 +0100 Subject: [PATCH 17/26] updates --- benchmarks/Schelling/schelling.py | 2 +- benchmarks/WolfSheep/wolf_sheep.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py index 733003221c5..dd8cd3c5e12 100644 --- a/benchmarks/Schelling/schelling.py +++ b/benchmarks/Schelling/schelling.py @@ -75,7 +75,7 @@ def __init__( """ super().__init__(seed=seed) self.simulator = simulator - self.simulator.setup() + self.simulator.setup(self) self.happy = 0 self.grid = OrthogonalMooreGrid( diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py index 99e44a00f58..33dfba5d17a 100644 --- a/benchmarks/WolfSheep/wolf_sheep.py +++ b/benchmarks/WolfSheep/wolf_sheep.py @@ -166,7 +166,7 @@ def __init__( self.height = height self.width = width self.simulator = simulator - self.simulator.setup() + self.simulator.setup(self) self.initial_sheep = initial_sheep self.initial_wolves = initial_wolves From 1948ba8b1342540df4d37d28eaf8cf62e14516b5 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 20:03:39 +0100 Subject: [PATCH 18/26] cleanup --- mesa/visualization/solara_viz.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 6712e3b3ae0..653ab52e4d7 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -95,21 +95,6 @@ def SolaraViz( if not isinstance(model, solara.Reactive): model = solara.use_reactive(model) # noqa: SH102, RUF100 - # def connect_to_model(): - # # Patch the step function to force updates - # original_step = model.value.step - # - # def step(): - # original_step() - # force_update() - # - # model.value.step = step - # Add a trigger to model itself - # model.value.force_update = force_update - # force_update() - - # solara.use_effect(connect_to_model, [model.value]) - # set up reactive model_parameters shared by ModelCreator and ModelController reactive_model_parameters = solara.use_reactive({}) From 4808be030a85a4a6e35e8105d18a1d0d00204fe4 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 22:06:35 +0100 Subject: [PATCH 19/26] initial commit --- mesa/examples/advanced/wolf_sheep/app.py | 25 +- mesa/examples/advanced/wolf_sheep/model.py | 5 +- mesa/experimental/solara_viz.py | 515 +++++++++++---------- 3 files changed, 270 insertions(+), 275 deletions(-) diff --git a/mesa/examples/advanced/wolf_sheep/app.py b/mesa/examples/advanced/wolf_sheep/app.py index fa66d2bf11e..a574cb854b9 100644 --- a/mesa/examples/advanced/wolf_sheep/app.py +++ b/mesa/examples/advanced/wolf_sheep/app.py @@ -1,6 +1,5 @@ from mesa.examples.advanced.wolf_sheep.agents import GrassPatch, Sheep, Wolf from mesa.examples.advanced.wolf_sheep.model import WolfSheep -from mesa.experimental.devs import ABMSimulator from mesa.visualization import ( Slider, SolaraViz, @@ -37,11 +36,7 @@ def wolf_sheep_portrayal(agent): model_params = { - "seed": { - "type": "InputText", - "value": 42, - "label": "Random Seed", - }, + # The following line is an example to showcase StaticText. "grass": { "type": "Select", "value": True, @@ -64,32 +59,26 @@ def wolf_sheep_portrayal(agent): } -def post_process_space(ax): +def post_process(ax): ax.set_aspect("equal") ax.set_xticks([]) ax.set_yticks([]) -def post_process_lines(ax): - ax.legend(loc="center left", bbox_to_anchor=(1, 0.9)) - - space_component = make_space_component( - wolf_sheep_portrayal, draw_grid=False, post_process=post_process_space + wolf_sheep_portrayal, draw_grid=False, post_process=post_process ) lineplot_component = make_plot_component( - {"Wolves": "tab:orange", "Sheep": "tab:cyan", "Grass": "tab:green"}, - post_process=post_process_lines, + {"Wolves": "tab:orange", "Sheep": "tab:cyan", "Grass": "tab:green"} ) -simulator = ABMSimulator() -model = WolfSheep(simulator, grass=True) +model = WolfSheep(grass=True) + page = SolaraViz( model, components=[space_component, lineplot_component], model_params=model_params, name="Wolf Sheep", - simulator=simulator, ) -page # noqa +page # noqa \ No newline at end of file diff --git a/mesa/examples/advanced/wolf_sheep/model.py b/mesa/examples/advanced/wolf_sheep/model.py index cc6ec6acc9f..01ffd97e72e 100644 --- a/mesa/examples/advanced/wolf_sheep/model.py +++ b/mesa/examples/advanced/wolf_sheep/model.py @@ -30,8 +30,8 @@ class WolfSheep(Model): def __init__( self, - width=20, height=20, + width=20, initial_sheep=100, initial_wolves=50, sheep_reproduce=0.04, @@ -69,6 +69,7 @@ def __init__( self.width = width self.grass = grass + # Create grid using experimental cell space self.grid = OrthogonalVonNeumannGrid( [self.height, self.width], @@ -128,4 +129,4 @@ def step(self): self.agents_by_type[Wolf].shuffle_do("step") # Collect data - self.datacollector.collect(self) + self.datacollector.collect(self) \ No newline at end of file diff --git a/mesa/experimental/solara_viz.py b/mesa/experimental/solara_viz.py index 135563bb2db..93aeb7ee02c 100644 --- a/mesa/experimental/solara_viz.py +++ b/mesa/experimental/solara_viz.py @@ -7,7 +7,6 @@ - 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 @@ -22,179 +21,146 @@ See the Visualization Tutorial and example models for more details. """ -import threading +from __future__ import annotations -import reacton.ipywidgets as widgets +import asyncio +import inspect +from collections.abc import Callable +from typing import TYPE_CHECKING, Literal + +import reacton.core 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 +import mesa.visualization.components.altair_components as components_altair +from mesa.visualization.user_param import Slider +from mesa.visualization.utils import force_update, update_counter + +if TYPE_CHECKING: + from mesa.model import Model -# 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 +@solara.component +def SolaraViz( + model: Model | solara.Reactive[Model], + components: list[reacton.core.Component] + | list[Callable[[Model], reacton.core.Component]] + | Literal["default"] = "default", + play_interval: int = 100, + model_params=None, + name: str | None = None, ): - """Create a card component for visualizing model space or measures. + """Solara visualization component. + + This component provides a visualization interface for a given model using Solara. + It supports various visualization components and allows for interactive model + stepping and parameter adjustments. 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) + model (Model | solara.Reactive[Model]): A Model instance or a reactive Model. + This is the main model to be visualized. If a non-reactive model is provided, + it will be converted to a reactive model. + components (list[solara.component] | Literal["default"], optional): List of solara + components or functions that return a solara component. + These components are used to render different parts of the model visualization. + Defaults to "default", which uses the default Altair space visualization. + play_interval (int, optional): Interval for playing the model steps in milliseconds. + This controls the speed of the model's automatic stepping. Defaults to 100 ms. + model_params (dict, optional): Parameters for (re-)instantiating a model. + Can include user-adjustable parameters and fixed parameters. Defaults to None. + name (str | None, optional): Name of the visualization. Defaults to the models class name. Returns: - rv.Card: A card component containing the visualization + solara.component: A Solara component that renders the visualization interface for the model. + + Example: + >>> model = MyModel() + >>> page = SolaraViz(model) + >>> page + + Notes: + - The `model` argument can be either a direct model instance or a reactive model. If a direct + model instance is provided, it will be converted to a reactive model using `solara.use_reactive`. + - The `play_interval` argument controls the speed of the model's automatic stepping. A lower + value results in faster stepping, while a higher value results in slower stepping. """ - 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 + if components == "default": + components = [components_altair.make_altair_space()] + if model_params is None: + model_params = {} + # Convert model to reactive + if not isinstance(model, solara.Reactive): + model = solara.use_reactive(model) # noqa: SH102, RUF100 -@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. + def connect_to_model(): + # Patch the step function to force updates + original_step = model.value.step - 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__ + def step(): + original_step() + force_update() - current_step = solara.use_reactive(0) + model.value.step = step + # Add a trigger to model itself + model.value.force_update = force_update + force_update() - # 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()}} - ) + solara.use_effect(connect_to_model, [model.value]) - # 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, - ], - ) + # set up reactive model_parameters shared by ModelCreator and ModelController + reactive_model_parameters = solara.use_reactive({}) - def handle_change_model_params(name: str, value: any): - """Update model parameters when user input changes.""" - set_model_parameters({**model_parameters, name: value}) + with solara.AppBar(): + solara.AppBarTitle(name if name else model.value.__class__.__name__) + + with solara.Sidebar(), solara.Column(): + with solara.Card("Controls"): + ModelController( + model, + model_parameters=reactive_model_parameters, + play_interval=play_interval, + ) + with solara.Card("Model Parameters"): + ModelCreator( + model, model_params, model_parameters=reactive_model_parameters + ) + with solara.Card("Information"): + ShowSteps(model.value) - # 3. Set up UI + ComponentsView(components, model.value) - 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() +def _wrap_component( + component: reacton.core.Component | Callable[[Model], reacton.core.Component], +) -> reacton.core.Component: + """Wrap a component in an auto-updated Solara component if needed.""" + if isinstance(component, reacton.core.Component): + return component - dependencies = [ - *list(model_parameters.values()), - current_step.value, - reactive_seed.value, - ] + @solara.component + def WrappedComponent(model): + update_counter.get() + return component(model) - # if space drawer is disabled, do not include it - layout_types = [{"Space": "default"}] if space_drawer else [] + return WrappedComponent - 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) +@solara.component +def ComponentsView( + components: list[reacton.core.Component] + | list[Callable[[Model], reacton.core.Component]], + model: Model, +): + """Display a list of components. - 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)) - ] + Args: + components: List of components to display + model: Model instance to pass to each component + """ + wrapped_components = [_wrap_component(component) for component in components] + items = [component(model) for component in wrapped_components] + grid_layout_initial = make_initial_grid_layout(num_components=len(items)) + grid_layout, set_grid_layout = solara.use_state(grid_layout_initial) solara.GridDraggable( items=items, grid_layout=grid_layout, @@ -208,106 +174,64 @@ def do_reseed(): @solara.component -def ModelController(model, play_interval, current_step, reset_counter): +def ModelController( + model: solara.Reactive[Model], + *, + model_parameters: dict | solara.Reactive[dict] = None, + play_interval: int = 100, +): """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 + model: Reactive model instance + model_parameters: Reactive parameters for (re-)instantiating a model. + play_interval: Interval for playing the model steps in milliseconds. + """ 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: + running = solara.use_reactive(True) + if model_parameters is None: + model_parameters = {} + model_parameters = solara.use_reactive(model_parameters) + + async def step(): + while playing.value and running.value: + await asyncio.sleep(play_interval / 1000) do_step() - else: - playing.value = False + + solara.lab.use_task( + step, dependencies=[playing.value, running.value], prefer_threaded=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() + model.value.step() + running.value = model.value.running - 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_reset(): + """Reset the model to its initial state.""" + playing.value = False + running.value = True + model.value = model.value = model.value.__class__(**model_parameters.value) - def do_pause(): - """Pause the model execution.""" - if (thread is None) or (not thread.is_alive()): - return - model.running = False - thread.join() + def do_play_pause(): + """Toggle play/pause.""" + playing.value = not playing.value - 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 - } - """ + with solara.Row(justify="space-between"): + solara.Button(label="Reset", color="primary", on_click=do_reset) + solara.Button( + label="▶" if not playing.value else "❚❚", + color="primary", + on_click=do_play_pause, + disabled=not running.value, ) - 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="Step", + color="primary", + on_click=do_step, + disabled=playing.value or not running.value, ) - 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): @@ -346,6 +270,91 @@ def check_param_is_fixed(param): return True +@solara.component +def ModelCreator( + model: solara.Reactive[Model], + user_params: dict, + *, + model_parameters: dict | solara.Reactive[dict] = None, +): + """Solara component for creating and managing a model instance with user-defined parameters. + + This component allows users to create a model instance with specified parameters and seed. + It provides an interface for adjusting model parameters and reseeding the model's random + number generator. + + Args: + model: A reactive model instance. This is the main model to be created and managed. + user_params: Parameters for (re-)instantiating a model. Can include user-adjustable parameters and fixed parameters. Defaults to None. + model_parameters: reactive parameters for reinitializing the model + + Returns: + solara.component: A Solara component that renders the model creation and management interface. + + Example: + >>> model = solara.reactive(MyModel()) + >>> model_params = { + >>> "param1": {"type": "slider", "value": 10, "min": 0, "max": 100}, + >>> "param2": {"type": "slider", "value": 5, "min": 1, "max": 10}, + >>> } + >>> creator = ModelCreator(model, model_params) + >>> creator + + Notes: + - The `model_params` argument should be a dictionary where keys are parameter names and values either fixed values + or are dictionaries containing parameter details such as type, value, min, and max. + - The `seed` argument ensures reproducibility by setting the initial seed for the model's random number generator. + - The component provides an interface for adjusting user-defined parameters and reseeding the model. + + """ + if model_parameters is None: + model_parameters = {} + model_parameters = solara.use_reactive(model_parameters) + + solara.use_effect( + lambda: _check_model_params(model.value.__class__.__init__, fixed_params), + [model.value], + ) + user_params, fixed_params = split_model_params(user_params) + + # set model_parameters to the default values for all parameters + model_parameters.value = { + **fixed_params, + **{k: v.get("value") for k, v in user_params.items()}, + } + + def on_change(name, value): + new_model_parameters = {**model_parameters.value, name: value} + model.value = model.value.__class__(**new_model_parameters) + model_parameters.value = new_model_parameters + + UserInputs(user_params, on_change=on_change) + + +def _check_model_params(init_func, model_params): + """Check if model parameters are valid for the model's initialization function. + + Args: + init_func: Model initialization function + model_params: Dictionary of model parameters + + Raises: + ValueError: If a parameter is not valid for the model's initialization function + """ + model_parameters = inspect.signature(init_func).parameters + for name in model_parameters: + if ( + model_parameters[name].default == inspect.Parameter.empty + and name not in model_params + and name != "self" + and name != "kwargs" + ): + raise ValueError(f"Missing required model parameter: {name}") + for name in model_params: + if name not in model_parameters and "kwargs" not in model_parameters: + raise ValueError(f"Invalid model parameter: {name}") + + @solara.component def UserInputs(user_params, on_change=None): """Initialize user inputs for configurable model parameters. @@ -354,8 +363,7 @@ def UserInputs(user_params, on_change=None): :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. + 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(): @@ -411,31 +419,21 @@ def change_handler(value, name=name): on_value=change_handler, value=options.get("value"), ) + elif input_type == "InputText": + solara.InputText( + 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): +def make_initial_grid_layout(num_components): """Create an initial grid layout for visualization components. Args: - layout_types: List of layout types (Space or Measure) + num_components: Number of components to display Returns: list: Initial grid layout configuration @@ -449,5 +447,12 @@ def make_initial_grid_layout(layout_types): "x": 6 * (i % 2), "y": 16 * (i - i % 2), } - for i in range(len(layout_types)) + for i in range(num_components) ] + + +@solara.component +def ShowSteps(model): + """Display the current step of the model.""" + update_counter.get() + return solara.Text(f"Step: {model.steps}") \ No newline at end of file From 1cc17b24a38ff29c5b7d8c34bec9fa51c9d42ca3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Nov 2024 21:10:00 +0000 Subject: [PATCH 20/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/examples/advanced/wolf_sheep/app.py | 2 +- mesa/examples/advanced/wolf_sheep/model.py | 3 +-- mesa/experimental/solara_viz.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/mesa/examples/advanced/wolf_sheep/app.py b/mesa/examples/advanced/wolf_sheep/app.py index a574cb854b9..94261021b6a 100644 --- a/mesa/examples/advanced/wolf_sheep/app.py +++ b/mesa/examples/advanced/wolf_sheep/app.py @@ -81,4 +81,4 @@ def post_process(ax): model_params=model_params, name="Wolf Sheep", ) -page # noqa \ No newline at end of file +page # noqa diff --git a/mesa/examples/advanced/wolf_sheep/model.py b/mesa/examples/advanced/wolf_sheep/model.py index 01ffd97e72e..6f8887d0491 100644 --- a/mesa/examples/advanced/wolf_sheep/model.py +++ b/mesa/examples/advanced/wolf_sheep/model.py @@ -69,7 +69,6 @@ def __init__( self.width = width self.grass = grass - # Create grid using experimental cell space self.grid = OrthogonalVonNeumannGrid( [self.height, self.width], @@ -129,4 +128,4 @@ def step(self): self.agents_by_type[Wolf].shuffle_do("step") # Collect data - self.datacollector.collect(self) \ No newline at end of file + self.datacollector.collect(self) diff --git a/mesa/experimental/solara_viz.py b/mesa/experimental/solara_viz.py index 93aeb7ee02c..023d449faf2 100644 --- a/mesa/experimental/solara_viz.py +++ b/mesa/experimental/solara_viz.py @@ -455,4 +455,4 @@ def make_initial_grid_layout(num_components): def ShowSteps(model): """Display the current step of the model.""" update_counter.get() - return solara.Text(f"Step: {model.steps}") \ No newline at end of file + return solara.Text(f"Step: {model.steps}") From 78b1dfc815e04217f4a2101c674a7be6677a7322 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 22:11:33 +0100 Subject: [PATCH 21/26] Update solara_viz.py --- mesa/visualization/solara_viz.py | 106 ++++++------------------------- 1 file changed, 21 insertions(+), 85 deletions(-) diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 653ab52e4d7..93aeb7ee02c 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -36,7 +36,6 @@ from mesa.visualization.utils import force_update, update_counter if TYPE_CHECKING: - from mesa.experimental.devs.simulator import Simulator from mesa.model import Model @@ -47,7 +46,6 @@ def SolaraViz( | list[Callable[[Model], reacton.core.Component]] | Literal["default"] = "default", play_interval: int = 100, - simulator: Simulator | None = None, model_params=None, name: str | None = None, ): @@ -67,7 +65,6 @@ def SolaraViz( Defaults to "default", which uses the default Altair space visualization. play_interval (int, optional): Interval for playing the model steps in milliseconds. This controls the speed of the model's automatic stepping. Defaults to 100 ms. - simulator: A simulator that controls the model (optional) model_params (dict, optional): Parameters for (re-)instantiating a model. Can include user-adjustable parameters and fixed parameters. Defaults to None. name (str | None, optional): Name of the visualization. Defaults to the models class name. @@ -95,6 +92,21 @@ def SolaraViz( if not isinstance(model, solara.Reactive): model = solara.use_reactive(model) # noqa: SH102, RUF100 + def connect_to_model(): + # Patch the step function to force updates + original_step = model.value.step + + def step(): + original_step() + force_update() + + model.value.step = step + # Add a trigger to model itself + model.value.force_update = force_update + force_update() + + solara.use_effect(connect_to_model, [model.value]) + # set up reactive model_parameters shared by ModelCreator and ModelController reactive_model_parameters = solara.use_reactive({}) @@ -103,19 +115,11 @@ def SolaraViz( with solara.Sidebar(), solara.Column(): with solara.Card("Controls"): - if simulator is None: - ModelController( - model, - model_parameters=reactive_model_parameters, - play_interval=play_interval, - ) - else: - SimulatorController( - model, - simulator, - model_parameters=reactive_model_parameters, - play_interval=play_interval, - ) + ModelController( + model, + model_parameters=reactive_model_parameters, + play_interval=play_interval, + ) with solara.Card("Model Parameters"): ModelCreator( model, model_params, model_parameters=reactive_model_parameters @@ -203,7 +207,6 @@ def do_step(): """Advance the model by one step.""" model.value.step() running.value = model.value.running - force_update() def do_reset(): """Reset the model to its initial state.""" @@ -231,73 +234,6 @@ def do_play_pause(): ) -@solara.component -def SimulatorController( - model: solara.Reactive[Model], - simulator, - *, - model_parameters: dict | solara.Reactive[dict] = None, - play_interval: int = 100, -): - """Create controls for model execution (step, play, pause, reset). - - Args: - model: Reactive model instance - simulator: Simulator instance - model_parameters: Reactive parameters for (re-)instantiating a model. - play_interval: Interval for playing the model steps in milliseconds. - - """ - playing = solara.use_reactive(False) - running = solara.use_reactive(True) - if model_parameters is None: - model_parameters = {} - model_parameters = solara.use_reactive(model_parameters) - - async def step(): - while playing.value and running.value: - await asyncio.sleep(play_interval / 1000) - do_step() - - solara.lab.use_task( - step, dependencies=[playing.value, running.value], prefer_threaded=False - ) - - def do_step(): - """Advance the model by one step.""" - simulator.run_for(1) # fixme - running.value = model.value.running - force_update() - - def do_reset(): - """Reset the model to its initial state.""" - playing.value = False - running.value = True - simulator.reset() - model.value = model.value = model.value.__class__( - simulator, **model_parameters.value - ) - - def do_play_pause(): - """Toggle play/pause.""" - playing.value = not playing.value - - with solara.Row(justify="space-between"): - solara.Button(label="Reset", color="primary", on_click=do_reset) - solara.Button( - label="▶" if not playing.value else "❚❚", - color="primary", - on_click=do_play_pause, - disabled=not running.value, - ) - solara.Button( - label="Step", - color="primary", - on_click=do_step, - disabled=playing.value or not running.value, - ) - - def split_model_params(model_params): """Split model parameters into user-adjustable and fixed parameters. @@ -519,4 +455,4 @@ def make_initial_grid_layout(num_components): def ShowSteps(model): """Display the current step of the model.""" update_counter.get() - return solara.Text(f"Step: {model.steps}") + return solara.Text(f"Step: {model.steps}") \ No newline at end of file From 3022ba99a83e567c548c163f64d1381ad5ab9691 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Nov 2024 21:11:41 +0000 Subject: [PATCH 22/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/visualization/solara_viz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 93aeb7ee02c..023d449faf2 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -455,4 +455,4 @@ def make_initial_grid_layout(num_components): def ShowSteps(model): """Display the current step of the model.""" update_counter.get() - return solara.Text(f"Step: {model.steps}") \ No newline at end of file + return solara.Text(f"Step: {model.steps}") From b9b1de5ae798c78c9e48ff62b5240bc74468cf8c Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 22:13:21 +0100 Subject: [PATCH 23/26] Update solara_viz.py --- mesa/experimental/solara_viz.py | 517 ++++++++++++++++---------------- 1 file changed, 256 insertions(+), 261 deletions(-) diff --git a/mesa/experimental/solara_viz.py b/mesa/experimental/solara_viz.py index 023d449faf2..d7e935dd2fc 100644 --- a/mesa/experimental/solara_viz.py +++ b/mesa/experimental/solara_viz.py @@ -7,6 +7,7 @@ - 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 @@ -21,146 +22,179 @@ See the Visualization Tutorial and example models for more details. """ -from __future__ import annotations +import threading -import asyncio -import inspect -from collections.abc import Callable -from typing import TYPE_CHECKING, Literal - -import reacton.core +import reacton.ipywidgets as widgets import solara +from solara.alias import rv -import mesa.visualization.components.altair_components as components_altair -from mesa.visualization.user_param import Slider -from mesa.visualization.utils import force_update, update_counter - -if TYPE_CHECKING: - from mesa.model import Model +import mesa.experimental.components.altair as components_altair +import mesa.experimental.components.matplotlib as components_matplotlib +from mesa.experimental.UserParam import Slider -@solara.component -def SolaraViz( - model: Model | solara.Reactive[Model], - components: list[reacton.core.Component] - | list[Callable[[Model], reacton.core.Component]] - | Literal["default"] = "default", - play_interval: int = 100, - model_params=None, - name: str | None = None, +# 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 ): - """Solara visualization component. - - This component provides a visualization interface for a given model using Solara. - It supports various visualization components and allows for interactive model - stepping and parameter adjustments. + """Create a card component for visualizing model space or measures. Args: - model (Model | solara.Reactive[Model]): A Model instance or a reactive Model. - This is the main model to be visualized. If a non-reactive model is provided, - it will be converted to a reactive model. - components (list[solara.component] | Literal["default"], optional): List of solara - components or functions that return a solara component. - These components are used to render different parts of the model visualization. - Defaults to "default", which uses the default Altair space visualization. - play_interval (int, optional): Interval for playing the model steps in milliseconds. - This controls the speed of the model's automatic stepping. Defaults to 100 ms. - model_params (dict, optional): Parameters for (re-)instantiating a model. - Can include user-adjustable parameters and fixed parameters. Defaults to None. - name (str | None, optional): Name of the visualization. Defaults to the models class name. + 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: - solara.component: A Solara component that renders the visualization interface for the model. - - Example: - >>> model = MyModel() - >>> page = SolaraViz(model) - >>> page - - Notes: - - The `model` argument can be either a direct model instance or a reactive model. If a direct - model instance is provided, it will be converted to a reactive model using `solara.use_reactive`. - - The `play_interval` argument controls the speed of the model's automatic stepping. A lower - value results in faster stepping, while a higher value results in slower stepping. + rv.Card: A card component containing the visualization """ - if components == "default": - components = [components_altair.make_altair_space()] - if model_params is None: - model_params = {} + 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 - # Convert model to reactive - if not isinstance(model, solara.Reactive): - model = solara.use_reactive(model) # noqa: SH102, RUF100 - def connect_to_model(): - # Patch the step function to force updates - original_step = model.value.step - - def step(): - original_step() - force_update() +@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. - model.value.step = step - # Add a trigger to model itself - model.value.force_update = force_update - force_update() + 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__ - solara.use_effect(connect_to_model, [model.value]) + current_step = solara.use_reactive(0) - # set up reactive model_parameters shared by ModelCreator and ModelController - reactive_model_parameters = solara.use_reactive({}) + # 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()}} + ) - with solara.AppBar(): - solara.AppBarTitle(name if name else model.value.__class__.__name__) - - with solara.Sidebar(), solara.Column(): - with solara.Card("Controls"): - ModelController( - model, - model_parameters=reactive_model_parameters, - play_interval=play_interval, - ) - with solara.Card("Model Parameters"): - ModelCreator( - model, model_params, model_parameters=reactive_model_parameters - ) - with solara.Card("Information"): - ShowSteps(model.value) + # 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, + ], + ) - ComponentsView(components, model.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 -def _wrap_component( - component: reacton.core.Component | Callable[[Model], reacton.core.Component], -) -> reacton.core.Component: - """Wrap a component in an auto-updated Solara component if needed.""" - if isinstance(component, reacton.core.Component): - return component + with solara.AppBar(): + solara.AppBarTitle(name) - @solara.component - def WrappedComponent(model): - update_counter.get() - return component(model) + # render layout and plot + def do_reseed(): + """Update the random seed for the model.""" + reactive_seed.value = model.random.random() - return WrappedComponent + 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 [] -@solara.component -def ComponentsView( - components: list[reacton.core.Component] - | list[Callable[[Model], reacton.core.Component]], - model: Model, -): - """Display a list of components. + if measures: + layout_types += [{"Measure": elem} for elem in range(len(measures))] - Args: - components: List of components to display - model: Model instance to pass to each component - """ - wrapped_components = [_wrap_component(component) for component in components] - items = [component(model) for component in wrapped_components] - grid_layout_initial = make_initial_grid_layout(num_components=len(items)) + 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, @@ -174,64 +208,106 @@ def ComponentsView( @solara.component -def ModelController( - model: solara.Reactive[Model], - *, - model_parameters: dict | solara.Reactive[dict] = None, - play_interval: int = 100, -): +def ModelController(model, play_interval, current_step, reset_counter): """Create controls for model execution (step, play, pause, reset). Args: - model: Reactive model instance - model_parameters: Reactive parameters for (re-)instantiating a model. - play_interval: Interval for playing the model steps in milliseconds. - + 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) - running = solara.use_reactive(True) - if model_parameters is None: - model_parameters = {} - model_parameters = solara.use_reactive(model_parameters) - - async def step(): - while playing.value and running.value: - await asyncio.sleep(play_interval / 1000) + 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() - - solara.lab.use_task( - step, dependencies=[playing.value, running.value], prefer_threaded=False - ) + else: + playing.value = False def do_step(): """Advance the model by one step.""" - model.value.step() - running.value = model.value.running + 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 do_reset(): - """Reset the model to its initial state.""" - playing.value = False - running.value = True - model.value = model.value = model.value.__class__(**model_parameters.value) + 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_play_pause(): - """Toggle play/pause.""" - playing.value = not playing.value + def do_pause(): + """Pause the model execution.""" + if (thread is None) or (not thread.is_alive()): + return + model.running = False + thread.join() - with solara.Row(justify="space-between"): - solara.Button(label="Reset", color="primary", on_click=do_reset) - solara.Button( - label="▶" if not playing.value else "❚❚", - color="primary", - on_click=do_play_pause, - disabled=not running.value, + 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 + } + """ ) - solara.Button( - label="Step", - color="primary", - on_click=do_step, - disabled=playing.value or not running.value, + 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): @@ -270,91 +346,6 @@ def check_param_is_fixed(param): return True -@solara.component -def ModelCreator( - model: solara.Reactive[Model], - user_params: dict, - *, - model_parameters: dict | solara.Reactive[dict] = None, -): - """Solara component for creating and managing a model instance with user-defined parameters. - - This component allows users to create a model instance with specified parameters and seed. - It provides an interface for adjusting model parameters and reseeding the model's random - number generator. - - Args: - model: A reactive model instance. This is the main model to be created and managed. - user_params: Parameters for (re-)instantiating a model. Can include user-adjustable parameters and fixed parameters. Defaults to None. - model_parameters: reactive parameters for reinitializing the model - - Returns: - solara.component: A Solara component that renders the model creation and management interface. - - Example: - >>> model = solara.reactive(MyModel()) - >>> model_params = { - >>> "param1": {"type": "slider", "value": 10, "min": 0, "max": 100}, - >>> "param2": {"type": "slider", "value": 5, "min": 1, "max": 10}, - >>> } - >>> creator = ModelCreator(model, model_params) - >>> creator - - Notes: - - The `model_params` argument should be a dictionary where keys are parameter names and values either fixed values - or are dictionaries containing parameter details such as type, value, min, and max. - - The `seed` argument ensures reproducibility by setting the initial seed for the model's random number generator. - - The component provides an interface for adjusting user-defined parameters and reseeding the model. - - """ - if model_parameters is None: - model_parameters = {} - model_parameters = solara.use_reactive(model_parameters) - - solara.use_effect( - lambda: _check_model_params(model.value.__class__.__init__, fixed_params), - [model.value], - ) - user_params, fixed_params = split_model_params(user_params) - - # set model_parameters to the default values for all parameters - model_parameters.value = { - **fixed_params, - **{k: v.get("value") for k, v in user_params.items()}, - } - - def on_change(name, value): - new_model_parameters = {**model_parameters.value, name: value} - model.value = model.value.__class__(**new_model_parameters) - model_parameters.value = new_model_parameters - - UserInputs(user_params, on_change=on_change) - - -def _check_model_params(init_func, model_params): - """Check if model parameters are valid for the model's initialization function. - - Args: - init_func: Model initialization function - model_params: Dictionary of model parameters - - Raises: - ValueError: If a parameter is not valid for the model's initialization function - """ - model_parameters = inspect.signature(init_func).parameters - for name in model_parameters: - if ( - model_parameters[name].default == inspect.Parameter.empty - and name not in model_params - and name != "self" - and name != "kwargs" - ): - raise ValueError(f"Missing required model parameter: {name}") - for name in model_params: - if name not in model_parameters and "kwargs" not in model_parameters: - raise ValueError(f"Invalid model parameter: {name}") - - @solara.component def UserInputs(user_params, on_change=None): """Initialize user inputs for configurable model parameters. @@ -363,7 +354,8 @@ def UserInputs(user_params, on_change=None): :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. + 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(): @@ -419,21 +411,31 @@ def change_handler(value, name=name): on_value=change_handler, value=options.get("value"), ) - elif input_type == "InputText": - solara.InputText( - label=label, - on_value=change_handler, - value=options.get("value"), - ) else: raise ValueError(f"{input_type} is not a supported input type") -def make_initial_grid_layout(num_components): +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: - num_components: Number of components to display + layout_types: List of layout types (Space or Measure) Returns: list: Initial grid layout configuration @@ -447,12 +449,5 @@ def make_initial_grid_layout(num_components): "x": 6 * (i % 2), "y": 16 * (i - i % 2), } - for i in range(num_components) - ] - - -@solara.component -def ShowSteps(model): - """Display the current step of the model.""" - update_counter.get() - return solara.Text(f"Step: {model.steps}") + for i in range(len(layout_types)) + ] \ No newline at end of file From 93d1e3be9c170dc26188f5b415731002be11dc69 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Nov 2024 21:13:37 +0000 Subject: [PATCH 24/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/experimental/solara_viz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesa/experimental/solara_viz.py b/mesa/experimental/solara_viz.py index d7e935dd2fc..135563bb2db 100644 --- a/mesa/experimental/solara_viz.py +++ b/mesa/experimental/solara_viz.py @@ -450,4 +450,4 @@ def make_initial_grid_layout(layout_types): "y": 16 * (i - i % 2), } for i in range(len(layout_types)) - ] \ No newline at end of file + ] From 29479ee671702dfebc9c2fc0c331772d18325f93 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 22:14:55 +0100 Subject: [PATCH 25/26] Update test_examples.py --- tests/test_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index f369639e74e..98d1d5809ee 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -69,5 +69,5 @@ def test_wolf_sheep(): # noqa: D103 from mesa.experimental.devs import ABMSimulator simulator = ABMSimulator() - model = WolfSheep(seed=42, simulator=simulator) + WolfSheep(seed=42, simulator=simulator) simulator.run_for(10) From 835b9856d9ea7de320d5236779ba3f9d840c5da1 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Sat, 9 Nov 2024 22:19:23 +0100 Subject: [PATCH 26/26] rename step to run_next_event --- mesa/experimental/devs/simulator.py | 2 +- tests/test_devs.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index 9445e189b6c..57749f038f4 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -109,7 +109,7 @@ def run_until(self, end_time: int | float) -> None: self._schedule_event(event) # reschedule event break - def step(self): + def run_next_event(self): """Execute the next event. Raises: diff --git a/tests/test_devs.py b/tests/test_devs.py index 5788235a940..883a74d1375 100644 --- a/tests/test_devs.py +++ b/tests/test_devs.py @@ -62,15 +62,15 @@ def test_devs_simulator(): fn = MagicMock() simulator.schedule_event_absolute(fn, 1.0) - simulator.step() + simulator.run_next_event() fn.assert_called_once() assert simulator.time == 1.0 - simulator.step() + simulator.run_next_event() assert simulator.time == 1.0 simulator = DEVSimulator() with pytest.raises(Exception): - simulator.step() + simulator.run_next_event() # cancel_event simulator = DEVSimulator()