Repository: projectmesa/mesa
Pull request #1338 (2022-05-23 02:54:09)

feat: Implement Choice UserParam class

codecov[bot]: # [Codecov](https://codecov.io/gh/projectmesa/mesa/pull/1338?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) Report
> Merging [#1338](https://codecov.io/gh/projectmesa/mesa/pull/1338?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) (499f729) into [main](https://codecov.io/gh/projectmesa/mesa/commit/232c8f71ac862c63ef077f3d9690c7a25780e0a4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) (232c8f7) will **decrease** coverage by `0.15%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##             main    #1338      +/-   ##
==========================================
- Coverage   90.80%   90.65%   -0.16%     
==========================================
  Files          19       19              
  Lines        1305     1305              
  Branches      253      251       -2     
==========================================
- Hits         1185     1183       -2     
- Misses         85       86       +1     
- Partials       35       36       +1     
```


| [Impacted Files](https://codecov.io/gh/projectmesa/mesa/pull/1338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) | Coverage Δ | |
|---|---|---|
| [mesa/visualization/UserParam.py](https://codecov.io/gh/projectmesa/mesa/pull/1338/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS92aXN1YWxpemF0aW9uL1VzZXJQYXJhbS5weQ==) | `93.85% <100.00%> (+0.46%)` | :arrow_up: |
| [mesa/batchrunner.py](https://codecov.io/gh/projectmesa/mesa/pull/1338/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS9iYXRjaHJ1bm5lci5weQ==) | `93.13% <0.00%> (-0.86%)` | :arrow_down: |
| [mesa/visualization/ModularVisualization.py](https://codecov.io/gh/projectmesa/mesa/pull/1338/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS92aXN1YWxpemF0aW9uL01vZHVsYXJWaXN1YWxpemF0aW9uLnB5) | `74.81% <0.00%> (-0.55%)` | :arrow_down: |
| [mesa/main.py](https://codecov.io/gh/projectmesa/mesa/pull/1338/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS9tYWluLnB5) | `96.00% <0.00%> (-0.43%)` | :arrow_down: |
| [mesa/datacollection.py](https://codecov.io/gh/projectmesa/mesa/pull/1338/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS9kYXRhY29sbGVjdGlvbi5weQ==) | `97.67% <0.00%> (-0.03%)` | :arrow_down: |
| [mesa/space.py](https://codecov.io/gh/projectmesa/mesa/pull/1338/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS9zcGFjZS5weQ==) | `94.42% <0.00%> (-0.02%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/projectmesa/mesa/pull/1338?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/projectmesa/mesa/pull/1338?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa). Last update [232c8f7...499f729](https://codecov.io/gh/projectmesa/mesa/pull/1338?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa).


Patch:
diff --git a/examples/pd_grid/pd_grid/server.py b/examples/pd_grid/pd_grid/server.py
index 545d4289..50095311 100644
--- a/examples/pd_grid/pd_grid/server.py
+++ b/examples/pd_grid/pd_grid/server.py
@@ -10,8 +10,7 @@ canvas_element = mesa.visualization.CanvasGrid(portrayPDAgent, 50, 50, 500, 500)
 model_params = {
     "height": 50,
     "width": 50,
-    "schedule_type": mesa.visualization.UserSettableParameter(
-        "choice",
+    "schedule_type": mesa.visualization.Choice(
         "Scheduler type",
         value="Random",
         choices=list(PdGrid.schedule_types.keys()),
diff --git a/examples/shape_example/shape_example/model.py b/examples/shape_example/shape_example/model.py
index f6c37b8f..5ffe114a 100644
--- a/examples/shape_example/shape_example/model.py
+++ b/examples/shape_example/shape_example/model.py
@@ -1,9 +1,7 @@
-from mesa import Model, Agent
-from mesa.space import SingleGrid
-from mesa.time import RandomActivation
+import mesa
 
 
-class Walker(Agent):
+class Walker(mesa.Agent):
     def __init__(self, unique_id, model, pos, heading=(1, 0)):
         super().__init__(unique_id, model)
         self.pos = pos
@@ -11,12 +9,12 @@ class Walker(Agent):
         self.headings = {(1, 0), (0, 1), (-1, 0), (0, -1)}
 
 
-class ShapeExample(Model):
+class ShapeExample(mesa.Model):
     def __init__(self, N=2, width=20, height=10):
         self.N = N  # num of agents
         self.headings = ((1, 0), (0, 1), (-1, 0), (0, -1))  # tuples are fast
-        self.grid = SingleGrid(width, height, torus=False)
-        self.schedule = RandomActivation(self)
+        self.grid = mesa.space.SingleGrid(width, height, torus=False)
+        self.schedule = mesa.time.RandomActivation(self)
         self.make_walker_agents()
         self.running = True
 
diff --git a/examples/shape_example/shape_example/server.py b/examples/shape_example/shape_example/server.py
index 6818f4a8..bec3a68d 100644
--- a/examples/shape_example/shape_example/server.py
+++ b/examples/shape_example/shape_example/server.py
@@ -1,5 +1,4 @@
-from mesa.visualization.modules import CanvasGrid
-from mesa.visualization.ModularVisualization import ModularServer
+import mesa
 
 from .model import Walker, ShapeExample
 
@@ -32,8 +31,10 @@ width = 15
 height = 10
 num_agents = 2
 pixel_ratio = 50
-grid = CanvasGrid(agent_draw, width, height, width * pixel_ratio, height * pixel_ratio)
-server = ModularServer(
+grid = mesa.visualization.CanvasGrid(
+    agent_draw, width, height, width * pixel_ratio, height * pixel_ratio
+)
+server = mesa.visualization.ModularServer(
     ShapeExample,
     [grid],
     "Shape Model Example",
diff --git a/examples/sugarscape_cg/sugarscape_cg/agents.py b/examples/sugarscape_cg/sugarscape_cg/agents.py
index c4bbef98..0920c54e 100644
--- a/examples/sugarscape_cg/sugarscape_cg/agents.py
+++ b/examples/sugarscape_cg/sugarscape_cg/agents.py
@@ -1,6 +1,6 @@
 import math
 
-from mesa import Agent
+import mesa
 
 
 def get_distance(pos_1, pos_2):
@@ -17,7 +17,7 @@ def get_distance(pos_1, pos_2):
     return math.sqrt(dx**2 + dy**2)
 
 
-class SsAgent(Agent):
+class SsAgent(mesa.Agent):
     def __init__(self, pos, model, moore=False, sugar=0, metabolism=0, vision=0):
         super().__init__(pos, model)
         self.pos = pos
@@ -72,7 +72,7 @@ class SsAgent(Agent):
             self.model.schedule.remove(self)
 
 
-class Sugar(Agent):
+class Sugar(mesa.Agent):
     def __init__(self, pos, model, max_sugar):
         super().__init__(pos, model)
         self.amount = max_sugar
diff --git a/examples/sugarscape_cg/sugarscape_cg/model.py b/examples/sugarscape_cg/sugarscape_cg/model.py
index f08bb57d..8487ed8d 100644
--- a/examples/sugarscape_cg/sugarscape_cg/model.py
+++ b/examples/sugarscape_cg/sugarscape_cg/model.py
@@ -9,15 +9,12 @@ Center for Connected Learning and Computer-Based Modeling,
 Northwestern University, Evanston, IL.
 """
 
-from mesa import Model
-from mesa.space import MultiGrid
-from mesa.datacollection import DataCollector
-from mesa.time import RandomActivationByType
+import mesa
 
 from .agents import SsAgent, Sugar
 
 
-class SugarscapeCg(Model):
+class SugarscapeCg(mesa.Model):
     """
     Sugarscape 2 Constant Growback
     """
@@ -37,9 +34,9 @@ class SugarscapeCg(Model):
         self.height = height
         self.initial_population = initial_population
 
-        self.schedule = RandomActivationByType(self)
-        self.grid = MultiGrid(self.width, self.height, torus=False)
-        self.datacollector = DataCollector(
+        self.schedule = mesa.time.RandomActivationByType(self)
+        self.grid = mesa.space.MultiGrid(self.width, self.height, torus=False)
+        self.datacollector = mesa.DataCollector(
             {"SsAgent": lambda m: m.schedule.get_type_count(SsAgent)}
         )
 
diff --git a/examples/sugarscape_cg/sugarscape_cg/server.py b/examples/sugarscape_cg/sugarscape_cg/server.py
index de48dbf6..7ceb3323 100644
--- a/examples/sugarscape_cg/sugarscape_cg/server.py
+++ b/examples/sugarscape_cg/sugarscape_cg/server.py
@@ -1,5 +1,4 @@
-from mesa.visualization.ModularVisualization import ModularServer
-from mesa.visualization.modules import CanvasGrid, ChartModule
+import mesa
 
 from .agents import SsAgent, Sugar
 from .model import SugarscapeCg
@@ -32,10 +31,12 @@ def SsAgent_portrayal(agent):
     return portrayal
 
 
-canvas_element = CanvasGrid(SsAgent_portrayal, 50, 50, 500, 500)
-chart_element = ChartModule([{"Label": "SsAgent", "Color": "#AA0000"}])
+canvas_element = mesa.visualization.CanvasGrid(SsAgent_portrayal, 50, 50, 500, 500)
+chart_element = mesa.visualization.ChartModule(
+    [{"Label": "SsAgent", "Color": "#AA0000"}]
+)
 
-server = ModularServer(
+server = mesa.visualization.ModularServer(
     SugarscapeCg, [canvas_element, chart_element], "Sugarscape 2 Constant Growback"
 )
 # server.launch()
diff --git a/examples/virus_on_network/virus_on_network/model.py b/examples/virus_on_network/virus_on_network/model.py
index 6f54f7e9..7d1e68f7 100644
--- a/examples/virus_on_network/virus_on_network/model.py
+++ b/examples/virus_on_network/virus_on_network/model.py
@@ -2,10 +2,7 @@ import math
 from enum import Enum
 import networkx as nx
 
-from mesa import Agent, Model
-from mesa.time import RandomActivation
-from mesa.datacollection import DataCollector
-from mesa.space import NetworkGrid
+import mesa
 
 
 class State(Enum):
@@ -30,7 +27,7 @@ def number_resistant(model):
     return number_state(model, State.RESISTANT)
 
 
-class VirusOnNetwork(Model):
+class VirusOnNetwork(mesa.Model):
     """A virus model with some number of agents"""
 
     def __init__(
@@ -47,8 +44,8 @@ class VirusOnNetwork(Model):
         self.num_nodes = num_nodes
         prob = avg_node_degree / self.num_nodes
         self.G = nx.erdos_renyi_graph(n=self.num_nodes, p=prob)
-        self.grid = NetworkGrid(self.G)
-        self.schedule = RandomActivation(self)
+        self.grid = mesa.space.NetworkGrid(self.G)
+        self.schedule = mesa.time.RandomActivation(self)
         self.initial_outbreak_size = (
             initial_outbreak_size if initial_outbreak_size <= num_nodes else num_nodes
         )
@@ -57,7 +54,7 @@ class VirusOnNetwork(Model):
         self.recovery_chance = recovery_chance
         self.gain_resistance_chance = gain_resistance_chance
 
-        self.datacollector = DataCollector(
+        self.datacollector = mesa.DataCollector(
             {
                 "Infected": number_infected,
                 "Susceptible": number_susceptible,
@@ -106,7 +103,7 @@ class VirusOnNetwork(Model):
             self.step()
 
 
-class VirusAgent(Agent):
+class VirusAgent(mesa.Agent):
     def __init__(
         self,
         unique_id,
diff --git a/examples/virus_on_network/virus_on_network/server.py b/examples/virus_on_network/virus_on_network/server.py
index 23524495..fe7f6028 100644
--- a/examples/virus_on_network/virus_on_network/server.py
+++ b/examples/virus_on_network/virus_on_network/server.py
@@ -1,10 +1,7 @@
 import math
 
-from mesa.visualization.ModularVisualization import ModularServer
-from mesa.visualization.UserParam import UserSettableParameter
-from mesa.visualization.modules import ChartModule
-from mesa.visualization.modules import NetworkModule
-from mesa.visualization.modules import TextElement
+import mesa
+
 from .model import VirusOnNetwork, State, number_infected
 
 
@@ -52,8 +49,8 @@ def network_portrayal(G):
     return portrayal
 
 
-network = NetworkModule(network_portrayal, 500, 500)
-chart = ChartModule(
+network = mesa.visualization.NetworkModule(network_portrayal, 500, 500)
+chart = mesa.visualization.ChartModule(
     [
         {"Label": "Infected", "Color": "#FF0000"},
         {"Label": "Susceptible", "Color": "#008000"},
@@ -62,7 +59,7 @@ chart = ChartModule(
 )
 
 
-class MyTextElement(TextElement):
+class MyTextElement(mesa.visualization.TextElement):
     def render(self, model):
         ratio = model.resistant_susceptible_ratio()
         ratio_text = "&infin;" if ratio is math.inf else f"{ratio:.2f}"
@@ -74,7 +71,7 @@ class MyTextElement(TextElement):
 
 
 model_params = {
-    "num_nodes": UserSettableParameter(
+    "num_nodes": mesa.visualization.UserSettableParameter(
         "slider",
         "Number of agents",
         10,
@@ -83,10 +80,10 @@ model_params = {
         1,
         description="Choose how many agents to include in the model",
     ),
-    "avg_node_degree": UserSettableParameter(
+    "avg_node_degree": mesa.visualization.UserSettableParameter(
         "slider", "Avg Node Degree", 3, 3, 8, 1, description="Avg Node Degree"
     ),
-    "initial_outbreak_size": UserSettableParameter(
+    "initial_outbreak_size": mesa.visualization.UserSettableParameter(
         "slider",
         "Initial Outbreak Size",
         1,
@@ -95,7 +92,7 @@ model_params = {
         1,
         description="Initial Outbreak Size",
     ),
-    "virus_spread_chance": UserSettableParameter(
+    "virus_spread_chance": mesa.visualization.UserSettableParameter(
         "slider",
         "Virus Spread Chance",
         0.4,
@@ -104,7 +101,7 @@ model_params = {
         0.1,
         description="Probability that susceptible neighbor will be infected",
     ),
-    "virus_check_frequency": UserSettableParameter(
+    "virus_check_frequency": mesa.visualization.UserSettableParameter(
         "slider",
         "Virus Check Frequency",
         0.4,
@@ -113,7 +110,7 @@ model_params = {
         0.1,
         description="Frequency the nodes check whether they are infected by " "a virus",
     ),
-    "recovery_chance": UserSettableParameter(
+    "recovery_chance": mesa.visualization.UserSettableParameter(
         "slider",
         "Recovery Chance",
         0.3,
@@ -122,7 +119,7 @@ model_params = {
         0.1,
         description="Probability that the virus will be removed",
     ),
-    "gain_resistance_chance": UserSettableParameter(
+    "gain_resistance_chance": mesa.visualization.UserSettableParameter(
         "slider",
         "Gain Resistance Chance",
         0.5,
@@ -134,7 +131,7 @@ model_params = {
     ),
 }
 
-server = ModularServer(
+server = mesa.visualization.ModularServer(
     VirusOnNetwork, [network, MyTextElement(), chart], "Virus Model", model_params
 )
 server.port = 8521
diff --git a/examples/wolf_sheep/wolf_sheep/random_walk.py b/examples/wolf_sheep/wolf_sheep/random_walk.py
index d6480f2c..1125589b 100644
--- a/examples/wolf_sheep/wolf_sheep/random_walk.py
+++ b/examples/wolf_sheep/wolf_sheep/random_walk.py
@@ -2,10 +2,10 @@
 Generalized behavior for random walking, one grid cell at a time.
 """
 
-from mesa import Agent
+import mesa
 
 
-class RandomWalker(Agent):
+class RandomWalker(mesa.Agent):
     """
     Class implementing random walker methods in a generalized manner.
 
diff --git a/examples/wolf_sheep/wolf_sheep/scheduler.py b/examples/wolf_sheep/wolf_sheep/scheduler.py
index d202cb87..e2a59fc7 100644
--- a/examples/wolf_sheep/wolf_sheep/scheduler.py
+++ b/examples/wolf_sheep/wolf_sheep/scheduler.py
@@ -1,9 +1,9 @@
 from typing import Type, Callable
-from mesa.agent import Agent
-from mesa.time import RandomActivationByType
 
+import mesa
 
-class RandomActivationByTypeFiltered(RandomActivationByType):
+
+class RandomActivationByTypeFiltered(mesa.time.RandomActivationByType):
     """
     A scheduler that overrides the get_type_count method to allow for filtering
     of agents by a function before counting.
@@ -14,7 +14,9 @@ class RandomActivationByTypeFiltered(RandomActivationByType):
     """
 
     def get_type_count(
-        self, type_class: Type[Agent], filter_func: Callable[[Agent], bool] = None
+        self,
+        type_class: Type[mesa.Agent],
+        filter_func: Callable[[mesa.Agent], bool] = None,
     ) -> int:
         """
         Returns the current number of agents of certain type in the queue that satisfy the filter function.
diff --git a/mesa/visualization/UserParam.py b/mesa/visualization/UserParam.py
index a4eb7c6e..b7d53e13 100644
--- a/mesa/visualization/UserParam.py
+++ b/mesa/visualization/UserParam.py
@@ -179,7 +179,7 @@ class Slider(UserParam):
         self.step = step
         self.description = description
 
-        # Validate option types to make sure values are supplied properly
+        # Validate option type to make sure values are supplied properly
         valid = not (
             self.value is None or self.min_value is None or self.max_value is None
         )
@@ -213,6 +213,43 @@ class Checkbox(UserParam):
         self._value = value
         self.description = description
 
-        # Validate option types to make sure values are supplied properly
+        # Validate option type to make sure values are supplied properly
         valid = isinstance(self.value, bool)
         self.maybe_raise_error(valid)
+
+
+class Choice(UserParam):
+    """
+    String-based dropdown input, for selecting choices within a model
+
+    Example:
+    choice_option = Choice(
+        'My Choice',
+        value='Default choice',
+        choices=['Default Choice', 'Alternate Choice']
+    )
+    """
+
+    def __init__(self, name="", value=None, choices=None, description=None):
+        self.param_type = CHOICE
+        self.name = name
+        self._value = value
+        self.choices = choices
+        self.description = description
+
+        # Validate option type to make sure values are supplied properly
+        valid = not (self.value is None or len(self.choices) == 0)
+        self.maybe_raise_error(valid)
+
+    @property
+    def value(self):
+        return self._value
+
+    @value.setter
+    def value(self, value):
+        self._value = value
+        if self._value not in self.choices:
+            print(
+                "Selected choice value not in available choices, selected first choice from 'choices' list"
+            )
+            self._value = self.choices[0]


Test patch:
diff --git a/tests/test_usersettableparam.py b/tests/test_usersettableparam.py
index 64c6de66..50aca435 100644
--- a/tests/test_usersettableparam.py
+++ b/tests/test_usersettableparam.py
@@ -1,5 +1,5 @@
 from unittest import TestCase
-from mesa.visualization.UserParam import UserSettableParameter, Slider, Checkbox
+from mesa.visualization.UserParam import UserSettableParameter, Slider, Checkbox, Choice
 
 
 class TestOption(TestCase):
@@ -12,6 +12,10 @@ class TestOption(TestCase):
             value="I am your default choice",
             choices=["I am your default choice", "I am your other choice"],
         )
+        self.choice_option_standalone = Choice(
+            value="I am your default choice",
+            choices=["I am your default choice", "I am your other choice"],
+        )
         self.slider_option = UserSettableParameter(
             "slider", value=123, min_value=100, max_value=200
         )
@@ -29,11 +33,12 @@ class TestOption(TestCase):
             assert not option.value
 
     def test_choice(self):
-        assert self.choice_option.value == "I am your default choice"
-        self.choice_option.value = "I am your other choice"
-        assert self.choice_option.value == "I am your other choice"
-        self.choice_option.value = "I am not an available choice"
-        assert self.choice_option.value == "I am your default choice"
+        for option in [self.choice_option, self.choice_option_standalone]:
+            assert option.value == "I am your default choice"
+            option.value = "I am your other choice"
+            assert option.value == "I am your other choice"
+            option.value = "I am not an available choice"
+            assert option.value == "I am your default choice"
 
     def test_slider(self):
         for option in [self.slider_option, self.slider_option_standalone]: