1
0
mirror of https://github.com/gsi-upm/soil synced 2024-11-21 18:52:28 +00:00
This commit is contained in:
J. Fernando Sánchez 2023-05-03 12:14:49 +02:00
parent 5e93399d58
commit f49be3af68
38 changed files with 913 additions and 836 deletions

View File

@ -13,11 +13,13 @@ For an explanation of the general changes in version 1.0, please refer to the fi
* Environments now have a class method to make them easier to use without a simulation`.run`. Notice that this is different from `run_model`, which is an instance method. * Environments now have a class method to make them easier to use without a simulation`.run`. Notice that this is different from `run_model`, which is an instance method.
* Ability to run simulations using mesa models * Ability to run simulations using mesa models
* The `soil.exporters` module to export the results of datacollectors (`model.datacollector`) into files at the end of trials/simulations * The `soil.exporters` module to export the results of datacollectors (`model.datacollector`) into files at the end of trials/simulations
* Agents can now have generators as a step function or a state. They work similar to normal functions, with one caveat in the case of `FSM`: only `time` values (or None) can be yielded, not a state. This is because the state will not change, it will be resumed after the yield, at the appropriate time. The return value *can* be a state, or a `(state, time)` tuple, just like in normal states. * Agents can now have generators or async functions as their step or as states. They work similar to normal functions, with one caveat in the case of `FSM`: only time values (a float, int or None) can be awaited or yielded, not a state. This is because the state will not change, it will be resumed after the yield, at the appropriate time. To return to a different state, use the `delay` and `at` functions of the state.
* Simulations can now specify a `matrix` with possible values for every simulation parameter. The final parameters will be calculated based on the `parameters` used and a cartesian product (i.e., all possible combinations) of each parameter. * Simulations can now specify a `matrix` with possible values for every simulation parameter. The final parameters will be calculated based on the `parameters` used and a cartesian product (i.e., all possible combinations) of each parameter.
* Simple debugging capabilities in `soil.debugging`, with a custom `pdb.Debugger` subclass that exposes commands to list agents and their status and set breakpoints on states (for FSM agents). Try it with `soil --debug <simulation file>` * Simple debugging capabilities in `soil.debugging`, with a custom `pdb.Debugger` subclass that exposes commands to list agents and their status and set breakpoints on states (for FSM agents). Try it with `soil --debug <simulation file>`
* The `agent.after` and `agent.at` methods, to avoid having to return a time manually.
### Changed ### Changed
* Configuration schema (`Simulation`) is very simplified. All simulations should be checked * Configuration schema (`Simulation`) is very simplified. All simulations should be checked
* Agents that wish to
* Model / environment variables are expected (but not enforced) to be a single value. This is done to more closely align with mesa * Model / environment variables are expected (but not enforced) to be a single value. This is done to more closely align with mesa
* `Exporter.iteration_end` now takes two parameters: `env` (same as before) and `params` (specific parameters for this environment). We considered including a `parameters` attribute in the environment, but this would not be compatible with mesa. * `Exporter.iteration_end` now takes two parameters: `env` (same as before) and `params` (specific parameters for this environment). We considered including a `parameters` attribute in the environment, but this would not be compatible with mesa.
* `num_trials` renamed to `iterations` * `num_trials` renamed to `iterations`
@ -26,6 +28,7 @@ For an explanation of the general changes in version 1.0, please refer to the fi
* Simulation results for every iteration of a simulation with the same name are stored in a single `sqlite` database * Simulation results for every iteration of a simulation with the same name are stored in a single `sqlite` database
### Removed ### Removed
* The `time.When` and `time.Cond` classes are removed
* Any `tsih` and `History` integration in the main classes. To record the state of environments/agents, just use a datacollector. In some cases this may be slower or consume more memory than the previous system. However, few cases actually used the full potential of the history, and it came at the cost of unnecessary complexity and worse performance for the majority of cases. * Any `tsih` and `History` integration in the main classes. To record the state of environments/agents, just use a datacollector. In some cases this may be slower or consume more memory than the previous system. However, few cases actually used the full potential of the history, and it came at the cost of unnecessary complexity and worse performance for the majority of cases.
## [0.20.8] ## [0.20.8]

View File

@ -1,40 +0,0 @@
---
name: MyExampleSimulation
max_time: 50
num_trials: 3
interval: 2
model_params:
topology:
params:
generator: barabasi_albert_graph
n: 100
m: 2
agents:
distribution:
- agent_class: SISaModel
topology: True
ratio: 0.1
state:
state_id: content
- agent_class: SISaModel
topology: True
ratio: .1
state:
state_id: discontent
- agent_class: SISaModel
topology: True
ratio: 0.8
state:
state_id: neutral
prob_infect: 0.075
neutral_discontent_spon_prob: 0.1
neutral_discontent_infected_prob: 0.3
neutral_content_spon_prob: 0.3
neutral_content_infected_prob: 0.4
discontent_neutral: 0.5
discontent_content: 0.5
variance_d_c: 0.2
content_discontent: 0.2
variance_c_d: 0.2
content_neutral: 0.2
standard_variance: 1

View File

@ -2,7 +2,7 @@ Welcome to Soil's documentation!
================================ ================================
Soil is an opinionated Agent-based Social Simulator in Python focused on Social Networks. Soil is an opinionated Agent-based Social Simulator in Python focused on Social Networks.
To get started developing your own simulations and agent behaviors, check out our :doc:`Tutorial <soil_tutorial>` and the `examples on GitHub <https://github.com/gsi-upm/soil/tree/master/examples>`. To get started developing your own simulations and agent behaviors, check out our :doc:`Tutorial <tutorial/soil_tutorial>` and the `examples on GitHub <https://github.com/gsi-upm/soil/tree/master/examples>`.
Soil can be installed through pip (see more details in the :doc:`installation` page):. Soil can be installed through pip (see more details in the :doc:`installation` page):.
@ -49,6 +49,7 @@ If you use Soil in your research, do not forget to cite this paper:
installation installation
Tutorial <tutorial/soil_tutorial> Tutorial <tutorial/soil_tutorial>
notes_v1.0 notes_v1.0
soil-vs
.. ..

View File

@ -1,22 +0,0 @@
Mesa compatibility
------------------
Soil is in the process of becoming fully compatible with MESA.
The idea is to provide a set of modular classes and functions that extend the functionality of mesa, whilst staying compatible.
In the end, it should be possible to add regular mesa agents to a soil simulation, or use a soil agent within a mesa simulation/model.
This is a non-exhaustive list of tasks to achieve compatibility:
- [ ] Integrate `soil.Simulation` with mesa's runners:
- [ ] `soil.Simulation` could mimic/become a `mesa.batchrunner`
- [ ] Integrate `soil.Environment` with `mesa.Model`:
- [x] `Soil.Environment` inherits from `mesa.Model`
- [x] `Soil.Environment` includes a Mesa-like Scheduler (see the `soil.time` module.
- [ ] Allow for `mesa.Model` to be used in a simulation.
- [ ] Integrate `soil.Agent` with `mesa.Agent`:
- [x] Rename agent.id to unique_id?
- [x] mesa agents can be used in soil simulations (see `examples/mesa`)
- [ ] Provide examples
- [ ] Using mesa modules in a soil simulation
- [ ] Using soil modules in a mesa simulation
- [ ] Document the new APIs and usage

View File

@ -1,4 +1,8 @@
### MESA Soil vs other ABM frameworks
============================
MESA
----
Starting with version 0.3, Soil has been redesigned to complement Mesa, while remaining compatible with it. Starting with version 0.3, Soil has been redesigned to complement Mesa, while remaining compatible with it.
That means that every component in Soil (i.e., Models, Environments, etc.) can be mixed with existing mesa components. That means that every component in Soil (i.e., Models, Environments, etc.) can be mixed with existing mesa components.
@ -10,3 +14,42 @@ Here are some reasons to use Soil instead of plain mesa:
- Functions to automatically populate a topology with an agent distribution (i.e., different ratios of agent class and state) - Functions to automatically populate a topology with an agent distribution (i.e., different ratios of agent class and state)
- The `soil.Simulation` class allows you to run multiple instances of the same experiment (i.e., multiple trials with the same parameters but a different randomness seed) - The `soil.Simulation` class allows you to run multiple instances of the same experiment (i.e., multiple trials with the same parameters but a different randomness seed)
- Reporting functions that aggregate multiple - Reporting functions that aggregate multiple
Mesa compatibility
~~~~~~~~~~~~~~~~~~
Soil is in the process of becoming fully compatible with MESA.
The idea is to provide a set of modular classes and functions that extend the functionality of mesa, whilst staying compatible.
In the end, it should be possible to add regular mesa agents to a soil simulation, or use a soil agent within a mesa simulation/model.
This is a non-exhaustive list of tasks to achieve compatibility:
.. |check| raw:: html
.. |uncheck| raw:: html
- |check| Integrate `soil.Simulation` with mesa's runners:
- |check| `soil.Simulation` can replace `mesa.batchrunner`
- |check| Integrate `soil.Environment` with `mesa.Model`:
- |check| `Soil.Environment` inherits from `mesa.Model`
- |check| `Soil.Environment` includes a Mesa-like Scheduler (see the `soil.time` module.
- |check| Allow for `mesa.Model` to be used in a simulation.
- |check| Integrate `soil.Agent` with `mesa.Agent`:
- |check| Rename agent.id to unique_id
- |check| mesa agents can be used in soil simulations (see `examples/mesa`)
- |check| Provide examples
- |check| Using mesa modules in a soil simulation (see `examples/mesa`)
- |uncheck| Using soil modules in a mesa simulation (see `examples/mesa`)
- |uncheck| Document the new APIs and usage

File diff suppressed because one or more lines are too long

1
examples/README.md Normal file
View File

@ -0,0 +1 @@
Some of these examples are close to real life simulations, whereas some others are only a demonstration of Soil's capatibilities.

View File

@ -86,7 +86,7 @@ class Driver(Evented, FSM):
earnings = 0 earnings = 0
def on_receive(self, msg, sender): def on_receive(self, msg, sender):
"""This is not a state. It will run (and block) every time check_messages is invoked""" """This is not a state. It will run (and block) every time process_messages is invoked"""
if self.journey is None and isinstance(msg, Journey) and msg.driver is None: if self.journey is None and isinstance(msg, Journey) and msg.driver is None:
msg.driver = self msg.driver = self
self.journey = msg self.journey = msg
@ -95,15 +95,14 @@ class Driver(Evented, FSM):
"""If there are no more passengers, stop forever""" """If there are no more passengers, stop forever"""
c = self.count_agents(agent_class=Passenger) c = self.count_agents(agent_class=Passenger)
self.debug(f"Passengers left {c}") self.debug(f"Passengers left {c}")
if not c: return c
self.die("No more passengers")
@default_state @state(default=True)
@state async def wandering(self):
def wandering(self):
"""Move around the city until a journey is accepted""" """Move around the city until a journey is accepted"""
target = None target = None
self.check_passengers() if not self.check_passengers():
return self.die("No passengers left")
self.journey = None self.journey = None
while self.journey is None: # No potential journeys detected (see on_receive) while self.journey is None: # No potential journeys detected (see on_receive)
if target is None or not self.move_towards(target): if target is None or not self.move_towards(target):
@ -111,14 +110,15 @@ class Driver(Evented, FSM):
self.model.grid.get_neighborhood(self.pos, moore=False) self.model.grid.get_neighborhood(self.pos, moore=False)
) )
self.check_passengers() if not self.check_passengers():
return self.die("No passengers left")
# This will call on_receive behind the scenes, and the agent's status will be updated # This will call on_receive behind the scenes, and the agent's status will be updated
self.check_messages() self.process_messages()
yield Delta(30) # Wait at least 30 seconds before checking again await self.delay(30) # Wait at least 30 seconds before checking again
try: try:
# Re-send the journey to the passenger, to confirm that we have been selected # Re-send the journey to the passenger, to confirm that we have been selected
self.journey = yield self.journey.passenger.ask(self.journey, timeout=60) self.journey = await self.journey.passenger.ask(self.journey, timeout=60, delay=5)
except events.TimedOut: except events.TimedOut:
# No journey has been accepted. Try again # No journey has been accepted. Try again
self.journey = None self.journey = None
@ -127,18 +127,19 @@ class Driver(Evented, FSM):
return self.driving return self.driving
@state @state
def driving(self): async def driving(self):
"""The journey has been accepted. Pick them up and take them to their destination""" """The journey has been accepted. Pick them up and take them to their destination"""
self.info(f"Driving towards Passenger {self.journey.passenger.unique_id}") self.info(f"Driving towards Passenger {self.journey.passenger.unique_id}")
while self.move_towards(self.journey.origin): while self.move_towards(self.journey.origin):
yield await self.delay()
self.info(f"Driving {self.journey.passenger.unique_id} from {self.journey.origin} to {self.journey.destination}") self.info(f"Driving {self.journey.passenger.unique_id} from {self.journey.origin} to {self.journey.destination}")
while self.move_towards(self.journey.destination, with_passenger=True): while self.move_towards(self.journey.destination, with_passenger=True):
yield await self.delay()
self.info("Arrived at destination") self.info("Arrived at destination")
self.earnings += self.journey.tip self.earnings += self.journey.tip
self.model.total_earnings += self.journey.tip self.model.total_earnings += self.journey.tip
self.check_passengers() if not self.check_passengers():
return self.die("No passengers left")
return self.wandering return self.wandering
def move_towards(self, target, with_passenger=False): def move_towards(self, target, with_passenger=False):
@ -167,7 +168,7 @@ class Passenger(Evented, FSM):
pos = None pos = None
def on_receive(self, msg, sender): def on_receive(self, msg, sender):
"""This is not a state. It will be run synchronously every time `check_messages` is run""" """This is not a state. It will be run synchronously every time `process_messages` is run"""
if isinstance(msg, Journey): if isinstance(msg, Journey):
self.journey = msg self.journey = msg
@ -175,7 +176,7 @@ class Passenger(Evented, FSM):
@default_state @default_state
@state @state
def asking(self): async def asking(self):
destination = ( destination = (
self.random.randint(0, self.model.grid.height-1), self.random.randint(0, self.model.grid.height-1),
self.random.randint(0, self.model.grid.width-1), self.random.randint(0, self.model.grid.width-1),
@ -195,9 +196,9 @@ class Passenger(Evented, FSM):
while not self.journey: while not self.journey:
self.debug(f"Waiting for responses at: { self.pos }") self.debug(f"Waiting for responses at: { self.pos }")
try: try:
# This will call check_messages behind the scenes, and the agent's status will be updated # This will call process_messages behind the scenes, and the agent's status will be updated
# If you want to avoid that, you can call it with: check=False # If you want to avoid that, you can call it with: check=False
yield self.received(expiration=expiration) await self.received(expiration=expiration, delay=10)
except events.TimedOut: except events.TimedOut:
self.info(f"Still no response. Waiting at: { self.pos }") self.info(f"Still no response. Waiting at: { self.pos }")
self.model.broadcast( self.model.broadcast(
@ -208,13 +209,13 @@ class Passenger(Evented, FSM):
return self.driving_home return self.driving_home
@state @state
def driving_home(self): async def driving_home(self):
while ( while (
self.pos[0] != self.journey.destination[0] self.pos[0] != self.journey.destination[0]
or self.pos[1] != self.journey.destination[1] or self.pos[1] != self.journey.destination[1]
): ):
try: try:
yield self.received(timeout=60) await self.received(timeout=60)
except events.TimedOut: except events.TimedOut:
pass pass

View File

@ -33,7 +33,7 @@ class GeneratorEnv(Environment):
self.add_agents(CounterModel) self.add_agents(CounterModel)
sim = Simulation(model=GeneratorEnv, max_steps=10, interval=1) sim = Simulation(model=GeneratorEnv, max_steps=10)
if __name__ == '__main__': if __name__ == '__main__':
sim.run(dump=False) sim.run(dump=False)

View File

@ -1,5 +1,4 @@
from soil.agents import FSM, state, default_state from soil.agents import FSM, state, default_state
from soil.time import Delta
class Fibonacci(FSM): class Fibonacci(FSM):
@ -11,17 +10,17 @@ class Fibonacci(FSM):
def counting(self): def counting(self):
self.log("Stopping at {}".format(self.now)) self.log("Stopping at {}".format(self.now))
prev, self["prev"] = self["prev"], max([self.now, self["prev"]]) prev, self["prev"] = self["prev"], max([self.now, self["prev"]])
return None, Delta(prev) return self.delay(prev)
class Odds(FSM): class Odds(FSM):
"""Agent that only executes in odd t_steps""" """Agent that only executes in odd t_steps"""
@default_state @state(default=True)
@state
def odds(self): def odds(self):
self.log("Stopping at {}".format(self.now)) self.log("Stopping at {}".format(self.now))
return None, Delta(1 + self.now % 2) return self.delay(1 + (self.now % 2))
from soil import Environment, Simulation from soil import Environment, Simulation
@ -35,7 +34,7 @@ class TimeoutsEnv(Environment):
self.add_agent(agent_class=Odds, node_id=1) self.add_agent(agent_class=Odds, node_id=1)
sim = Simulation(model=TimeoutsEnv, max_steps=10, interval=1) sim = Simulation(model=TimeoutsEnv, max_steps=10)
if __name__ == "__main__": if __name__ == "__main__":
sim.run(dump=False) sim.run(dump=False)

View File

@ -1,7 +1,7 @@
from soil import Simulation from soil import Simulation
from social_wealth import MoneyEnv, graph_generator from social_wealth import MoneyEnv, graph_generator
sim = Simulation(name="mesa_sim", dump=False, max_steps=10, interval=2, model=MoneyEnv, parameters=dict(generator=graph_generator, N=10, width=50, height=50)) sim = Simulation(name="mesa_sim", dump=False, max_steps=10, model=MoneyEnv, parameters=dict(generator=graph_generator, N=10, width=50, height=50))
if __name__ == "__main__": if __name__ == "__main__":
sim.run() sim.run()

View File

@ -1,5 +1,4 @@
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, Simulation from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, Simulation
from soil.time import Delta
from enum import Enum from enum import Enum
from collections import Counter from collections import Counter
import logging import logging
@ -35,7 +34,7 @@ class Rabbit(FSM, NetworkAgent):
self.info("I am a newborn.") self.info("I am a newborn.")
self.birth = self.now self.birth = self.now
self.offspring = 0 self.offspring = 0
return self.youngling, Delta(self.sexual_maturity - self.age) return self.youngling.delay(self.sexual_maturity - self.age)
@state @state
def youngling(self): def youngling(self):

View File

@ -1,9 +1,7 @@
""" """
Example of setting a
Example of a fully programmatic simulation, without definition files. Example of a fully programmatic simulation, without definition files.
""" """
from soil import Simulation, agents, Environment from soil import Simulation, agents, Environment
from soil.time import Delta
class MyAgent(agents.FSM): class MyAgent(agents.FSM):
@ -11,22 +9,22 @@ class MyAgent(agents.FSM):
An agent that first does a ping An agent that first does a ping
""" """
defaults = {"pong_counts": 2} max_pongs = 2
@agents.default_state @agents.default_state
@agents.state @agents.state
def ping(self): def ping(self):
self.info("Ping") self.info("Ping")
return self.pong, Delta(self.random.expovariate(1 / 16)) return self.pong.delay(self.random.expovariate(1 / 16))
@agents.state @agents.state
def pong(self): def pong(self):
self.info("Pong") self.info("Pong")
self.pong_counts -= 1 self.max_pongs -= 1
self.info(str(self.pong_counts)) self.info(str(self.max_pongs), "pongs remaining")
if self.pong_counts < 1: if self.max_pongs < 1:
return self.die() return self.die()
return None, Delta(self.random.expovariate(1 / 16)) return self.delay(self.random.expovariate(1 / 16))
class RandomEnv(Environment): class RandomEnv(Environment):

View File

@ -1,5 +1,6 @@
import networkx as nx import networkx as nx
from soil.agents import Geo, NetworkAgent, FSM, custom, state, default_state from soil.agents import NetworkAgent, FSM, custom, state, default_state
from soil.agents.geo import Geo
from soil import Environment, Simulation from soil import Environment, Simulation
from soil.parameters import * from soil.parameters import *
from soil.utils import int_seed from soil.utils import int_seed
@ -39,8 +40,8 @@ class TerroristEnvironment(Environment):
HavenModel HavenModel
], [self.ratio_civil, self.ratio_leader, self.ratio_training, self.ratio_haven]) ], [self.ratio_civil, self.ratio_leader, self.ratio_training, self.ratio_haven])
def generator(self, *args, **kwargs): def generator(self, *args, seed=None, **kwargs):
return nx.random_geometric_graph(*args, **kwargs, seed=int_seed(self._seed)) return nx.random_geometric_graph(*args, **kwargs, seed=seed or int_seed(self._seed))
class TerroristSpreadModel(FSM, Geo): class TerroristSpreadModel(FSM, Geo):
""" """

View File

@ -21,5 +21,4 @@ class TorvaldsEnv(Environment):
sim = Simulation(name='torvalds_example', sim = Simulation(name='torvalds_example',
max_steps=10, max_steps=10,
interval=2,
model=TorvaldsEnv) model=TorvaldsEnv)

View File

@ -1 +1 @@
1.0.0rc2 1.0.0rc3

View File

@ -16,7 +16,6 @@ except NameError:
basestring = str basestring = str
from pathlib import Path from pathlib import Path
from .analysis import *
from .agents import * from .agents import *
from . import agents from . import agents
from .simulation import * from .simulation import *

View File

@ -25,6 +25,35 @@ from .. import serialization, network, utils, time, config
IGNORED_FIELDS = ("model", "logger") IGNORED_FIELDS = ("model", "logger")
def decorate_generator_step(func, name):
@wraps(func)
def decorated(self):
while True:
if self._coroutine is None:
self._coroutine = func(self)
try:
if self._last_except:
val = self._coroutine.throw(self._last_except)
else:
val = self._coroutine.send(self._last_return)
except StopIteration as ex:
self._coroutine = None
val = ex.value
finally:
self._last_return = None
self._last_except = None
return float(val) if val is not None else val
return decorated
def decorate_normal_func(func, name):
@wraps(func)
def decorated(self):
val = func(self)
return float(val) if val is not None else val
return decorated
class MetaAgent(ABCMeta): class MetaAgent(ABCMeta):
def __new__(mcls, name, bases, namespace): def __new__(mcls, name, bases, namespace):
defaults = {} defaults = {}
@ -36,34 +65,23 @@ class MetaAgent(ABCMeta):
new_nmspc = { new_nmspc = {
"_defaults": defaults, "_defaults": defaults,
"_last_return": None,
"_last_except": None,
} }
for attr, func in namespace.items(): for attr, func in namespace.items():
if attr == "step" and inspect.isgeneratorfunction(func): if attr == "step":
orig_func = func if inspect.isgeneratorfunction(func) or inspect.iscoroutinefunction(func):
new_nmspc["_coroutine"] = None func = decorate_generator_step(func, attr)
new_nmspc.update({
@wraps(func) "_last_return": None,
def func(self): "_last_except": None,
while True: "_coroutine": None,
if not self._coroutine: })
self._coroutine = orig_func(self) elif inspect.isasyncgenfunction(func):
try: raise ValueError("Illegal step function: {}. It probably mixes both async/await and yield".format(func))
if self._last_except: elif inspect.isfunction(func):
return self._coroutine.throw(self._last_except) func = decorate_normal_func(func, attr)
else: else:
return self._coroutine.send(self._last_return) raise ValueError("Illegal step function: {}".format(func))
except StopIteration as ex:
self._coroutine = None
return ex.value
finally:
self._last_return = None
self._last_except = None
func.id = name or func.__name__
func.is_default = False
new_nmspc[attr] = func new_nmspc[attr] = func
elif ( elif (
isinstance(func, types.FunctionType) isinstance(func, types.FunctionType)
@ -74,9 +92,13 @@ class MetaAgent(ABCMeta):
new_nmspc[attr] = func new_nmspc[attr] = func
elif attr == "defaults": elif attr == "defaults":
defaults.update(func) defaults.update(func)
elif inspect.isfunction(func):
new_nmspc[attr] = func
else: else:
defaults[attr] = copy(func) defaults[attr] = copy(func)
# Add attributes for their use in the decorated functions
return super().__new__(mcls, name, bases, new_nmspc) return super().__new__(mcls, name, bases, new_nmspc)
@ -92,7 +114,7 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
Any attribute that is not preceded by an underscore (`_`) will also be added to its state. Any attribute that is not preceded by an underscore (`_`) will also be added to its state.
""" """
def __init__(self, unique_id, model, name=None, init=True, interval=None, **kwargs): def __init__(self, unique_id, model, name=None, init=True, **kwargs):
assert isinstance(unique_id, int) assert isinstance(unique_id, int)
super().__init__(unique_id=unique_id, model=model) super().__init__(unique_id=unique_id, model=model)
@ -102,7 +124,6 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
self.alive = True self.alive = True
self.interval = interval or self.get("interval", 1)
logger = utils.logger.getChild(getattr(self.model, "id", self.model)).getChild( logger = utils.logger.getChild(getattr(self.model, "id", self.model)).getChild(
self.name self.name
) )
@ -111,13 +132,18 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
if hasattr(self, "level"): if hasattr(self, "level"):
self.logger.setLevel(self.level) self.logger.setLevel(self.level)
for k in self._defaults:
v = getattr(model, k, None)
if v is not None:
setattr(self, k, v)
for (k, v) in self._defaults.items(): for (k, v) in self._defaults.items():
if not hasattr(self, k) or getattr(self, k) is None: if not hasattr(self, k) or getattr(self, k) is None:
setattr(self, k, deepcopy(v)) setattr(self, k, deepcopy(v))
for (k, v) in kwargs.items(): for (k, v) in kwargs.items():
setattr(self, k, v) setattr(self, k, v)
if init: if init:
self.init() self.init()
@ -189,11 +215,13 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
return it return it
def get(self, key, default=None): def get(self, key, default=None):
if key in self: try:
return self[key] return getattr(self, key)
elif key in self.model: except AttributeError:
return self.model[key] try:
return default return getattr(self.model, key)
except AttributeError:
return default
@property @property
def now(self): def now(self):
@ -206,13 +234,14 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def die(self, msg=None): def die(self, msg=None):
if msg: if msg:
self.info("Agent dying:", msg) self.info("Agent dying:", msg)
self.debug(f"agent dying") else:
self.debug(f"agent dying")
self.alive = False self.alive = False
try: try:
self.model.schedule.remove(self) self.model.schedule.remove(self)
except KeyError: except KeyError:
pass pass
return time.NEVER return time.Delay(time.INFINITY)
def step(self): def step(self):
raise NotImplementedError("Agent must implement step method") raise NotImplementedError("Agent must implement step method")
@ -266,6 +295,12 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def __repr__(self): def __repr__(self):
return f"{self.__class__.__name__}({self.unique_id})" return f"{self.__class__.__name__}({self.unique_id})"
def at(self, at):
return time.Delay(float(at) - self.now)
def delay(self, delay=1):
return time.Delay(delay)
def prob(prob, random): def prob(prob, random):
""" """
@ -450,8 +485,10 @@ def filter_agents(
state = state or dict() state = state or dict()
state.update(kwargs) state.update(kwargs)
for k, v in state.items(): for k, vs in state.items():
f = filter(lambda agent: getattr(agent, k, None) == v, f) if not isinstance(vs, list):
vs = [vs]
f = filter(lambda agent: any(getattr(agent, k, None) == v for v in vs), f)
if limit is not None: if limit is not None:
f = islice(f, limit) f = islice(f, limit)
@ -658,15 +695,6 @@ from .SISaModel import *
from .CounterModel import * from .CounterModel import *
try:
import scipy
from .Geo import Geo
except ImportError:
import sys
print("Could not load the Geo Agent, scipy is not installed", file=sys.stderr)
def custom(cls, **kwargs): def custom(cls, **kwargs):
"""Create a new class from a template class and keyword arguments""" """Create a new class from a template class and keyword arguments"""
return type(cls.__name__, (cls,), kwargs) return type(cls.__name__, (cls,), kwargs)

View File

@ -1,42 +1,8 @@
from . import BaseAgent from . import BaseAgent
from ..events import Message, Tell, Ask, TimedOut from ..events import Message, Tell, Ask, TimedOut
from ..time import BaseCond
from functools import partial from functools import partial
from collections import deque from collections import deque
from types import coroutine
class ReceivedOrTimeout(BaseCond):
def __init__(
self, agent, expiration=None, timeout=None, check=True, ignore=False, **kwargs
):
if expiration is None:
if timeout is not None:
expiration = agent.now + timeout
self.expiration = expiration
self.ignore = ignore
self.check = check
super().__init__(**kwargs)
def expired(self, time):
return self.expiration and self.expiration < time
def ready(self, agent, time):
return len(agent._inbox) or self.expired(time)
def return_value(self, agent):
if not self.ignore and self.expired(agent.now):
raise TimedOut("No messages received")
if self.check:
agent.check_messages()
return None
def schedule_next(self, time, delta, first=False):
if self._delta is not None:
delta = self._delta
return (time + delta, self)
def __repr__(self):
return f"ReceivedOrTimeout(expires={self.expiration})"
class EventedAgent(BaseAgent): class EventedAgent(BaseAgent):
@ -48,30 +14,45 @@ class EventedAgent(BaseAgent):
def on_receive(self, *args, **kwargs): def on_receive(self, *args, **kwargs):
pass pass
def received(self, *args, **kwargs): @coroutine
return ReceivedOrTimeout(self, *args, **kwargs) def received(self, expiration=None, timeout=60, delay=1, process=True):
if not expiration:
expiration = self.now + timeout
while self.now < expiration:
if self._inbox:
msgs = self._inbox
if process:
self.process_messages()
return msgs
yield self.delay(delay)
raise TimedOut("No message received")
def tell(self, msg, sender=None): def tell(self, msg, sender=None):
self._inbox.append(Tell(timestamp=self.now, payload=msg, sender=sender)) self._inbox.append(Tell(timestamp=self.now, payload=msg, sender=sender))
def ask(self, msg, timeout=None, **kwargs): @coroutine
def ask(self, msg, expiration=None, timeout=None, delay=1):
ask = Ask(timestamp=self.now, payload=msg, sender=self) ask = Ask(timestamp=self.now, payload=msg, sender=self)
self._inbox.append(ask) self._inbox.append(ask)
expiration = float("inf") if timeout is None else self.now + timeout expiration = float("inf") if timeout is None else self.now + timeout
return ask.replied(expiration=expiration, **kwargs) while self.now < expiration:
if ask.reply:
return ask.reply
yield self.delay(delay)
raise TimedOut("No reply received")
def check_messages(self): def process_messages(self):
changed = False valid = list()
while self._inbox: for msg in self._inbox:
msg = self._inbox.popleft()
self._processed += 1 self._processed += 1
if msg.expired(self.now): if msg.expired(self.now):
continue continue
changed = True valid.append(msg)
reply = self.on_receive(msg.payload, sender=msg.sender) reply = self.on_receive(msg.payload, sender=msg.sender)
if isinstance(msg, Ask): if isinstance(msg, Ask):
msg.reply = reply msg.reply = reply
return changed self._inbox.clear()
return valid
Evented = EventedAgent Evented = EventedAgent

View File

@ -1,47 +1,69 @@
from . import MetaAgent, BaseAgent from . import MetaAgent, BaseAgent
from ..time import Delta from .. import time
from types import coroutine
from functools import partial, wraps from functools import partial, wraps
import inspect import inspect
class State:
__slots__ = ("awaitable", "f", "generator", "name", "default")
def __init__(self, f, name, default, generator, awaitable):
self.f = f
self.name = name
self.generator = generator
self.awaitable = awaitable
self.default = default
@coroutine
def step(self, obj):
if self.generator or self.awaitable:
f = self.f
next_state = yield from f(obj)
return next_state
else:
return self.f(obj)
@property
def id(self):
return self.name
def __call__(self, *args, **kwargs):
raise Exception("States should not be called directly")
class UnboundState(State):
def bind(self, obj):
bs = BoundState(self.f, self.name, self.default, self.generator, self.awaitable, obj=obj)
setattr(obj, self.name, bs)
return bs
class BoundState(State):
__slots__ = ("obj", )
def __init__(self, *args, obj):
super().__init__(*args)
self.obj = obj
def delay(self, delta=0):
return self, self.obj.delay(delta)
def at(self, when):
return self, self.obj.at(when)
def state(name=None, default=False): def state(name=None, default=False):
def decorator(func, name=None): def decorator(func, name=None):
""" """
A state function should return either a state id, or a tuple (state_id, when) A state function should return either a state id, or a tuple (state_id, when)
The default value for state_id is the current state id. The default value for state_id is the current state id.
The default value for when is the interval defined in the environment.
""" """
if inspect.isgeneratorfunction(func): name = name or func.__name__
orig_func = func generator = inspect.isgeneratorfunction(func)
awaitable = inspect.iscoroutinefunction(func) or inspect.isasyncgen(func)
@wraps(func) return UnboundState(func, name, default, generator, awaitable)
def func(self):
while True:
if not self._coroutine:
self._coroutine = orig_func(self)
try:
if self._last_except:
n = self._coroutine.throw(self._last_except)
else:
n = self._coroutine.send(self._last_return)
if n:
return None, n
return n
except StopIteration as ex:
self._coroutine = None
next_state = ex.value
if next_state is not None:
self._set_state(next_state)
return next_state
finally:
self._last_return = None
self._last_except = None
func.id = name or func.__name__
func.is_default = default
return func
if callable(name): if callable(name):
return decorator(name) return decorator(name)
@ -50,7 +72,7 @@ def state(name=None, default=False):
def default_state(func): def default_state(func):
func.is_default = True func.default = True
return func return func
@ -62,42 +84,45 @@ class MetaFSM(MetaAgent):
for i in bases: for i in bases:
if isinstance(i, MetaFSM): if isinstance(i, MetaFSM):
for state_id, state in i._states.items(): for state_id, state in i._states.items():
if state.is_default: if state.default:
default_state = state default_state = state
states[state_id] = state states[state_id] = state
# Add new states # Add new states
for attr, func in namespace.items(): for attr, func in namespace.items():
if hasattr(func, "id"): if isinstance(func, State):
if func.is_default: if func.default:
default_state = func default_state = func
states[func.id] = func states[func.name] = func
namespace.update( namespace.update(
{ {
"_default_state": default_state, "_state": default_state,
"_states": states, "_states": states,
} }
) )
return super(MetaFSM, mcls).__new__( cls = super(MetaFSM, mcls).__new__(
mcls=mcls, name=name, bases=bases, namespace=namespace mcls=mcls, name=name, bases=bases, namespace=namespace
) )
for (k, v) in states.items():
setattr(cls, k, v)
return cls
class FSM(BaseAgent, metaclass=MetaFSM): class FSM(BaseAgent, metaclass=MetaFSM):
def __init__(self, init=True, **kwargs): def __init__(self, init=True, state_id=None, **kwargs):
super().__init__(**kwargs, init=False) super().__init__(**kwargs, init=False)
if not hasattr(self, "state_id"): if state_id is not None:
if not self._default_state: self._set_state(state_id)
raise ValueError( # If more than "dead" state is defined, but no default state
"No default state specified for {}".format(self.unique_id) if len(self._states) > 1 and not self._state:
) raise ValueError(
self.state_id = self._default_state.id f"No default state specified for {type(self)}({self.unique_id})"
)
for (k, v) in self._states.items():
setattr(self, k, v.bind(self))
self._coroutine = None
self.default_interval = Delta(self.model.interval)
self._set_state(self.state_id)
if init: if init:
self.init() self.init()
@ -105,44 +130,46 @@ class FSM(BaseAgent, metaclass=MetaFSM):
def states(cls): def states(cls):
return list(cls._states.keys()) return list(cls._states.keys())
@property
def state_id(self):
return self._state.name
def set_state(self, value):
if self.now > 0:
raise ValueError("Cannot change state after init")
self._set_state(value)
def step(self): def step(self):
self.debug(f"Agent {self.unique_id} @ state {self.state_id}")
self._check_alive() self._check_alive()
next_state = self._states[self.state_id](self) next_state = yield from self._state.step(self)
when = None
try: try:
next_state, *when = next_state next_state, when = next_state
if not when: except (TypeError, ValueError) as ex:
when = None try:
elif len(when) == 1: self._set_state(next_state)
when = when[0] return None
else: except ValueError:
raise ValueError( return next_state
"Too many values returned. Only state (and time) allowed"
)
except TypeError:
pass
if next_state is not None: self._set_state(next_state)
self._set_state(next_state) return when
return when or self.default_interval def _set_state(self, state):
if state is None:
def _set_state(self, state, when=None): return
if hasattr(state, "id"): if isinstance(state, str):
state = state.id if state not in self._states:
if state not in self._states: raise ValueError("{} is not a valid state".format(state))
state = self._states[state]
if not isinstance(state, State):
raise ValueError("{} is not a valid state".format(state)) raise ValueError("{} is not a valid state".format(state))
self.state_id = state self._state = state
if when is not None:
self.model.schedule.add(self, when=when)
return state
def die(self, *args, **kwargs): def die(self, *args, **kwargs):
return self.dead, super().die(*args, **kwargs) super().die(*args, **kwargs)
return self.dead.at(time.INFINITY)
@state @state
def dead(self): def dead(self):
return self.die() return time.INFINITY

View File

@ -6,7 +6,7 @@ class NetworkAgent(BaseAgent):
super().__init__(*args, init=False, **kwargs) super().__init__(*args, init=False, **kwargs)
self.G = topology or self.model.G self.G = topology or self.model.G
assert self.G assert self.G is not None, "Network agents should have a network"
if node_id is None: if node_id is None:
nodes = self.random.choices(list(self.G.nodes), k=len(self.G)) nodes = self.random.choices(list(self.G.nodes), k=len(self.G))
for n_id in nodes: for n_id in nodes:
@ -25,8 +25,6 @@ class NetworkAgent(BaseAgent):
def count_neighbors(self, state_id=None, **kwargs): def count_neighbors(self, state_id=None, **kwargs):
return len(self.get_neighbors(state_id=state_id, **kwargs)) return len(self.get_neighbors(state_id=state_id, **kwargs))
if init:
self.init()
def iter_neighbors(self, **kwargs): def iter_neighbors(self, **kwargs):
return self.iter_agents(limit_neighbors=True, **kwargs) return self.iter_agents(limit_neighbors=True, **kwargs)

View File

@ -28,7 +28,8 @@ def plot(env, agent_df=None, model_df=None, steps=False, ignore=["agent_count",
Results = namedtuple("Results", ["config", "parameters", "env", "agents"]) Results = namedtuple("Results", ["config", "parameters", "env", "agents"])
#TODO implement reading from CSV and SQLITE #TODO implement reading from CSV
def read_sql(fpath=None, name=None, include_agents=False): def read_sql(fpath=None, name=None, include_agents=False):
if not (fpath is None) ^ (name is None): if not (fpath is None) ^ (name is None):
raise ValueError("Specify either a path or a simulation name") raise ValueError("Specify either a path or a simulation name")

View File

@ -9,7 +9,7 @@ class SoilCollector(MDC):
if 'agent_count' not in model_reporters: if 'agent_count' not in model_reporters:
model_reporters['agent_count'] = lambda m: m.schedule.get_agent_count() model_reporters['agent_count'] = lambda m: m.schedule.get_agent_count()
if 'time' not in model_reporters: if 'time' not in model_reporters:
model_reporters['time'] = lambda m: m.now model_reporters['time'] = lambda m: m.schedule.time
# if 'state_id' not in agent_reporters: # if 'state_id' not in agent_reporters:
# agent_reporters['state_id'] = lambda agent: getattr(agent, 'state_id', None) # agent_reporters['state_id'] = lambda agent: getattr(agent, 'state_id', None)

View File

@ -16,7 +16,7 @@ import networkx as nx
from mesa import Model, Agent from mesa import Model, Agent
from . import agents as agentmod, datacollection, serialization, utils, time, network, events from . import agents as agentmod, datacollection, utils, time, network, events
# TODO: maybe add metaclass to read attributes of a model # TODO: maybe add metaclass to read attributes of a model
@ -35,6 +35,7 @@ class BaseEnvironment(Model):
""" """
collector_class = datacollection.SoilCollector collector_class = datacollection.SoilCollector
schedule_class = time.TimedActivation
def __new__(cls, def __new__(cls,
*args: Any, *args: Any,
@ -49,7 +50,6 @@ class BaseEnvironment(Model):
self = super().__new__(cls, *args, seed=seed, **kwargs) self = super().__new__(cls, *args, seed=seed, **kwargs)
self.dir_path = dir_path or os.getcwd() self.dir_path = dir_path or os.getcwd()
collector_class = collector_class or cls.collector_class collector_class = collector_class or cls.collector_class
collector_class = serialization.deserialize(collector_class)
self.datacollector = collector_class( self.datacollector = collector_class(
model_reporters=model_reporters, model_reporters=model_reporters,
agent_reporters=agent_reporters, agent_reporters=agent_reporters,
@ -60,7 +60,7 @@ class BaseEnvironment(Model):
if isinstance(v, property): if isinstance(v, property):
v = v.fget v = v.fget
if getattr(v, "add_to_report", False): if getattr(v, "add_to_report", False):
self.add_model_reporter(k, v) self.add_model_reporter(k, k)
return self return self
@ -70,8 +70,8 @@ class BaseEnvironment(Model):
id="unnamed_env", id="unnamed_env",
seed="default", seed="default",
dir_path=None, dir_path=None,
schedule_class=time.TimedActivation, schedule=None,
interval=1, schedule_class=None,
logger = None, logger = None,
agents: Optional[Dict] = None, agents: Optional[Dict] = None,
collector_class: type = datacollection.SoilCollector, collector_class: type = datacollection.SoilCollector,
@ -94,13 +94,11 @@ class BaseEnvironment(Model):
else: else:
self.logger = utils.logger.getChild(self.id) self.logger = utils.logger.getChild(self.id)
if schedule_class is None: self.schedule = schedule
schedule_class = time.TimedActivation if schedule is None:
else: if schedule_class is None:
schedule_class = serialization.deserialize(schedule_class) schedule_class = self.schedule_class
self.schedule = schedule_class(self)
self.interval = interval
self.schedule = schedule_class(self)
for (k, v) in env_params.items(): for (k, v) in env_params.items():
self[k] = v self[k] = v
@ -161,7 +159,7 @@ class BaseEnvironment(Model):
if unique_id is None: if unique_id is None:
unique_id = self.next_id() unique_id = self.next_id()
a = serialization.deserialize(agent_class)(unique_id=unique_id, model=self, **agent) a = agent_class(unique_id=unique_id, model=self, **agent)
self.schedule.add(a) self.schedule.add(a)
return a return a
@ -204,14 +202,14 @@ class BaseEnvironment(Model):
def add_model_reporter(self, name, func=None): def add_model_reporter(self, name, func=None):
if not func: if not func:
func = lambda env: getattr(env, name) func = name
self.datacollector._new_model_reporter(name, func) self.datacollector._new_model_reporter(name, func)
def add_agent_reporter(self, name, agent_type=None): def add_agent_reporter(self, name, reporter=None, agent_type=None):
if agent_type: if not agent_type and not reporter:
reporter = lambda a: getattr(a, name) if isinstance(a, agent_type) else None reporter = name
else: elif agent_type:
reporter = lambda a: getattr(a, name, None) reporter = lambda a: reporter(a) if isinstance(a, agent_type) else None
self.datacollector._new_agent_reporter(name, reporter) self.datacollector._new_agent_reporter(name, reporter)
@classmethod @classmethod
@ -278,8 +276,6 @@ class NetworkEnvironment(BaseEnvironment):
super().__init__(*args, **kwargs, init=False) super().__init__(*args, **kwargs, init=False)
self.agent_class = agent_class self.agent_class = agent_class
if agent_class:
self.agent_class = serialization.deserialize(agent_class)
if self.agent_class: if self.agent_class:
self.populate_network(self.agent_class) self.populate_network(self.agent_class)
self._check_agent_nodes() self._check_agent_nodes()
@ -309,7 +305,15 @@ class NetworkEnvironment(BaseEnvironment):
elif path is not None: elif path is not None:
topology = network.from_topology(path, dir_path=self.dir_path) topology = network.from_topology(path, dir_path=self.dir_path)
elif generator is not None: elif generator is not None:
topology = network.from_params(generator=generator, dir_path=self.dir_path, **network_params) params = dict(generator=generator,
dir_path=self.dir_path,
seed=self.random,
**network_params)
try:
topology = network.from_params(**params)
except TypeError:
del params["seed"]
topology = network.from_params(**params)
else: else:
raise ValueError("topology must be a networkx.Graph or a string, or network_generator must be provided") raise ValueError("topology must be a networkx.Graph or a string, or network_generator must be provided")
self.G = topology self.G = topology

View File

@ -1,4 +1,3 @@
from .time import BaseCond
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@ -24,29 +23,9 @@ class Reply(Message):
source: Message source: Message
class ReplyCond(BaseCond):
def __init__(self, ask, *args, **kwargs):
self._ask = ask
super().__init__(*args, **kwargs)
def ready(self, agent, time):
return self._ask.reply is not None or self._ask.expired(time)
def return_value(self, agent):
if self._ask.expired(agent.now):
raise TimedOut()
return self._ask.reply
def __repr__(self):
return f"ReplyCond({self._ask.id})"
class Ask(Message): class Ask(Message):
reply: Message = None reply: Message = None
def replied(self, expiration=None):
return ReplyCond(self)
class Tell(Message): class Tell(Message):
pass pass

View File

@ -2,11 +2,9 @@ import os
import sys import sys
from time import time as current_time from time import time as current_time
from io import BytesIO from io import BytesIO
from sqlalchemy import create_engine
from textwrap import dedent, indent from textwrap import dedent, indent
import matplotlib.pyplot as plt
import networkx as nx import networkx as nx
import pandas as pd import pandas as pd
@ -124,6 +122,9 @@ class SQLite(Exporter):
if not self.dump: if not self.dump:
logger.debug("NOT dumping results") logger.debug("NOT dumping results")
return return
from sqlalchemy import create_engine
self.dbpath = os.path.join(self.outdir, f"{self.simulation.name}.sqlite") self.dbpath = os.path.join(self.outdir, f"{self.simulation.name}.sqlite")
logger.info("Dumping results to %s", self.dbpath) logger.info("Dumping results to %s", self.dbpath)
if self.simulation.backup: if self.simulation.backup:
@ -175,7 +176,6 @@ class csv(Exporter):
df.to_csv(f) df.to_csv(f)
# TODO: reimplement GEXF exporting without history
class gexf(Exporter): class gexf(Exporter):
def iteration_end(self, env, *args, **kwargs): def iteration_end(self, env, *args, **kwargs):
if not self.dump: if not self.dump:
@ -186,8 +186,7 @@ class gexf(Exporter):
"[GEXF] Dumping simulation {} iteration {}".format(self.simulation.name, env.id) "[GEXF] Dumping simulation {} iteration {}".format(self.simulation.name, env.id)
): ):
with self.output("{}.gexf".format(env.id), mode="wb") as f: with self.output("{}.gexf".format(env.id), mode="wb") as f:
network.dump_gexf(env.history_to_graph(), f) nx.write_gexf(env.G, f)
self.dump_gexf(env, f)
class dummy(Exporter): class dummy(Exporter):
@ -210,6 +209,7 @@ class dummy(Exporter):
class graphdrawing(Exporter): class graphdrawing(Exporter):
def iteration_end(self, env, *args, **kwargs): def iteration_end(self, env, *args, **kwargs):
import matplotlib.pyplot as plt
# Outside effects # Outside effects
f = plt.figure() f = plt.figure()
nx.draw( nx.draw(

View File

@ -103,7 +103,13 @@ def load_config(cfg):
yield from load_files(cfg) yield from load_files(cfg)
builtins = importlib.import_module("builtins") _BUILTINS = None
def builtins():
global _BUILTINS
if not _BUILTINS:
_BUILTINS = importlib.import_module("builtins")
return _BUILTINS
KNOWN_MODULES = { KNOWN_MODULES = {
'soil': None, 'soil': None,
@ -163,7 +169,7 @@ def name(value, known_modules=KNOWN_MODULES):
if not isinstance(value, type): # Get the class name first if not isinstance(value, type): # Get the class name first
value = type(value) value = type(value)
tname = value.__name__ tname = value.__name__
if hasattr(builtins, tname): if hasattr(builtins(), tname):
return tname return tname
modname = value.__module__ modname = value.__module__
if modname == "__main__": if modname == "__main__":
@ -178,7 +184,7 @@ def name(value, known_modules=KNOWN_MODULES):
def serializer(type_): def serializer(type_):
if type_ != "str" and hasattr(builtins, type_): if type_ != "str":
return repr return repr
return lambda x: x return lambda x: x
@ -216,8 +222,8 @@ def deserializer(type_, known_modules=KNOWN_MODULES):
return lambda x="": x return lambda x="": x
if type_ == "None": if type_ == "None":
return lambda x=None: None return lambda x=None: None
if hasattr(builtins, type_): # Check if it's a builtin type if hasattr(builtins(), type_): # Check if it's a builtin type
cls = getattr(builtins, type_) cls = getattr(builtins(), type_)
return lambda x=None: ast.literal_eval(x) if x is not None else cls() return lambda x=None: ast.literal_eval(x) if x is not None else cls()
match = IS_CLASS.match(type_) match = IS_CLASS.match(type_)
if match: if match:

View File

@ -23,7 +23,7 @@ import json
from . import serialization, exporters, utils, basestring, agents from . import serialization, exporters, utils, basestring, agents
from .environment import Environment from . import environment
from .utils import logger, run_and_return_exceptions from .utils import logger, run_and_return_exceptions
from .debugging import set_trace from .debugging import set_trace
@ -49,8 +49,6 @@ def _iter_queued():
# TODO: change documentation for simulation # TODO: change documentation for simulation
# TODO: rename iterations to iterations
# TODO: make parameters a dict of iterable/any
@dataclass @dataclass
class Simulation: class Simulation:
""" """
@ -68,7 +66,6 @@ class Simulation:
dir_path: The directory path to use for the simulation. dir_path: The directory path to use for the simulation.
max_time: The maximum time to run the simulation. max_time: The maximum time to run the simulation.
max_steps: The maximum number of steps to run the simulation. max_steps: The maximum number of steps to run the simulation.
interval: The interval to use for the simulation.
iterations: The number of iterations (times) to run the simulation. iterations: The number of iterations (times) to run the simulation.
num_processes: The number of processes to use for the simulation. If greater than one, simulations will be performed in parallel. This may make debugging and error handling difficult. num_processes: The number of processes to use for the simulation. If greater than one, simulations will be performed in parallel. This may make debugging and error handling difficult.
tables: The tables to use in the simulation datacollector tables: The tables to use in the simulation datacollector
@ -96,7 +93,6 @@ class Simulation:
dir_path: str = field(default_factory=lambda: os.getcwd()) dir_path: str = field(default_factory=lambda: os.getcwd())
max_time: float = None max_time: float = None
max_steps: int = None max_steps: int = None
interval: int = 1
iterations: int = 1 iterations: int = 1
num_processes: Optional[int] = 1 num_processes: Optional[int] = 1
exporters: Optional[List[str]] = field(default_factory=lambda: [exporters.default]) exporters: Optional[List[str]] = field(default_factory=lambda: [exporters.default])
@ -126,15 +122,9 @@ class Simulation:
if isinstance(self.model, str): if isinstance(self.model, str):
self.model = serialization.deserialize(self.model) self.model = serialization.deserialize(self.model)
def deserialize_reporters(reporters): self.agent_reporters = self.agent_reporters
for (k, v) in reporters.items(): self.model_reporters = self.model_reporters
if isinstance(v, str) and v.startswith("py:"): self.tables = self.tables
reporters[k] = serialization.deserialize(v.split(":", 1)[1])
return reporters
self.agent_reporters = deserialize_reporters(self.agent_reporters)
self.model_reporters = deserialize_reporters(self.model_reporters)
self.tables = deserialize_reporters(self.tables)
self.id = f"{self.name}_{current_time()}" self.id = f"{self.name}_{current_time()}"
def run(self, **kwargs): def run(self, **kwargs):
@ -142,15 +132,6 @@ class Simulation:
if kwargs: if kwargs:
return replace(self, **kwargs).run() return replace(self, **kwargs).run()
self.logger.debug(
dedent(
"""
Simulation:
---
"""
)
+ self.to_yaml()
)
param_combinations = self._collect_params(**kwargs) param_combinations = self._collect_params(**kwargs)
if _AVOID_RUNNING: if _AVOID_RUNNING:
_QUEUED.extend((self, param) for param in param_combinations) _QUEUED.extend((self, param) for param in param_combinations)
@ -244,7 +225,6 @@ class Simulation:
id=iteration_id, id=iteration_id,
seed=f"{self.seed}_iteration_{iteration_id}", seed=f"{self.seed}_iteration_{iteration_id}",
dir_path=self.dir_path, dir_path=self.dir_path,
interval=self.interval,
logger=self.logger.getChild(iteration_id), logger=self.logger.getChild(iteration_id),
agent_reporters=agent_reporters, agent_reporters=agent_reporters,
model_reporters=model_reporters, model_reporters=model_reporters,
@ -359,8 +339,11 @@ def iter_from_py(pyfile, module_name='imported_file', **kwargs):
for sim in _iter_queued(): for sim in _iter_queued():
sims.append(sim) sims.append(sim)
if not sims: if not sims:
for (_name, sim) in inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, Simulation)): for (_name, env) in inspect.getmembers(module,
sims.append(sim(**kwargs)) lambda x: inspect.isclass(x) and
issubclass(x, environment.Environment) and
(getattr(x, "__module__", None) != environment.__name__)):
sims.append(Simulation(model=env, **kwargs))
del sys.modules[module_name] del sys.modules[module_name]
assert not _AVOID_RUNNING assert not _AVOID_RUNNING
if not sims: if not sims:

View File

@ -1,7 +1,9 @@
from mesa.time import BaseScheduler from mesa.time import BaseScheduler
from queue import Empty from queue import Empty
from heapq import heappush, heappop, heapreplace from heapq import heappush, heappop, heapreplace
from collections import deque
import math import math
import logging
from inspect import getsource from inspect import getsource
from numbers import Number from numbers import Number
@ -13,119 +15,54 @@ from mesa import Agent as MesaAgent
INFINITY = float("inf") INFINITY = float("inf")
class Delay:
"""A delay object which can be used both as a return value and as an awaitable (in async code)."""
__slots__ = ("delta", )
def __init__(self, delta):
self.delta = float(delta)
def __float__(self):
return self.delta
def __await__(self):
return (yield self.delta)
class DeadAgent(Exception): class DeadAgent(Exception):
pass pass
class When: class PQueueActivation(BaseScheduler):
def __init__(self, time): """
if isinstance(time, When): A scheduler which activates each agent with a delay returned by the agent's step method.
return time If no delay is returned, a default of 1 is used.
self._time = time
def abs(self, time):
return self._time
def schedule_next(self, time, delta, first=False):
return (self._time, None)
NEVER = When(INFINITY)
class Delta(When):
def __init__(self, delta):
self._delta = delta
def abs(self, time):
return self._time + self._delta
def __eq__(self, other):
if isinstance(other, Delta):
return self._delta == other._delta
return False
def schedule_next(self, time, delta, first=False):
return (time + self._delta, None)
def __repr__(self):
return str(f"Delta({self._delta})")
class BaseCond:
def __init__(self, msg=None, delta=None, eager=False):
self._msg = msg
self._delta = delta
self.eager = eager
def schedule_next(self, time, delta, first=False):
if first and self.eager:
return (time, self)
if self._delta:
delta = self._delta
return (time + delta, self)
def return_value(self, agent):
return None
def __repr__(self):
return self._msg or self.__class__.__name__
class Cond(BaseCond):
def __init__(self, func, *args, **kwargs):
self._func = func
super().__init__(*args, **kwargs)
def ready(self, agent, time):
return self._func(agent)
def __repr__(self):
if self._msg:
return self._msg
return str(f'Cond("{dedent(getsource(self._func)).strip()}")')
class TimedActivation(BaseScheduler):
"""A scheduler which activates each agent when the agent requests.
In each activation, each agent will update its 'next_time'. In each activation, each agent will update its 'next_time'.
""" """
def __init__(self, *args, shuffle=True, **kwargs): def __init__(self, *args, shuffle=True, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._next = {}
self._queue = [] self._queue = []
self._shuffle = shuffle self._shuffle = shuffle
# self.step_interval = getattr(self.model, "interval", 1)
self.step_interval = self.model.interval
self.logger = getattr(self.model, "logger", logger).getChild(f"time_{ self.model }") self.logger = getattr(self.model, "logger", logger).getChild(f"time_{ self.model }")
self.next_time = self.time self.next_time = self.time
def add(self, agent: MesaAgent, when=None): def add(self, agent: MesaAgent, when=None):
if when is None: if when is None:
when = self.time when = self.time
elif isinstance(when, When): else:
when = when.abs() when = float(when)
self._schedule(agent, None, when) self._schedule(agent, None, when)
super().add(agent) super().add(agent)
def _schedule(self, agent, condition=None, when=None, replace=False): def _schedule(self, agent, when=None, replace=False):
if condition: if when is None:
if not when: when = self.time
when, condition = condition.schedule_next(
when or self.time, self.step_interval
)
else:
if when is None:
when = self.time + self.step_interval
condition = None
if self._shuffle: if self._shuffle:
key = (when, self.model.random.random(), condition) key = (when, self.model.random.random())
else: else:
key = (when, agent.unique_id, condition) key = (when, agent.unique_id)
self._next[agent.unique_id] = key
if replace: if replace:
heapreplace(self._queue, (key, agent)) heapreplace(self._queue, (key, agent))
else: else:
@ -137,70 +74,104 @@ class TimedActivation(BaseScheduler):
an agent will signal when it wants to be scheduled next. an agent will signal when it wants to be scheduled next.
""" """
self.logger.debug(f"Simulation step {self.time}") if self.time == INFINITY:
if not self.model.running or self.time == INFINITY:
return return
self.logger.debug(f"Queue length: %s", len(self._queue)) next_time = INFINITY
now = self.time
while self._queue: while self._queue:
((when, _id, cond), agent) = self._queue[0] ((when, _id), agent) = self._queue[0]
if when > self.time: if when > now:
next_time = when
break break
if cond:
if not cond.ready(agent, self.time):
self._schedule(agent, cond, replace=True)
continue
try:
agent._last_return = cond.return_value(agent)
except Exception as ex:
agent._last_except = ex
else:
agent._last_return = None
agent._last_except = None
self.logger.debug("Stepping agent %s", agent)
self._next.pop(agent.unique_id, None)
try: try:
returned = agent.step() when = agent.step() or 1
when += now
except DeadAgent: except DeadAgent:
agent.alive = False
heappop(self._queue) heappop(self._queue)
continue continue
# Check status for MESA agents if when == INFINITY:
if not getattr(agent, "alive", True):
heappop(self._queue) heappop(self._queue)
continue continue
if returned: self._schedule(agent, when, replace=True)
next_check = returned.schedule_next(
self.time, self.step_interval, first=True
)
self._schedule(agent, when=next_check[0], condition=next_check[1], replace=True)
else:
next_check = (self.time + self.step_interval, None)
self._schedule(agent, replace=True)
self.steps += 1 self.steps += 1
if not self._queue: self.time = next_time
if next_time == INFINITY:
self.model.running = False self.model.running = False
self.time = INFINITY self.time = INFINITY
return return
next_time = self._queue[0][0][0]
if next_time < self.time: class TimedActivation(BaseScheduler):
raise Exception( def __init__(self, *args, shuffle=True, **kwargs):
f"An agent has been scheduled for a time in the past, there is probably an error ({when} < {self.time})" super().__init__(*args, **kwargs)
) self._queue = deque()
self.logger.debug("Updating time step: %s -> %s ", self.time, next_time) self._shuffle = shuffle
self.logger = getattr(self.model, "logger", logger).getChild(f"time_{ self.model }")
self.next_time = self.time
self.time = next_time def add(self, agent: MesaAgent, when=None):
if when is None:
when = self.time
else:
when = float(when)
self._schedule(agent, None, when)
super().add(agent)
def _schedule(self, agent, when=None, replace=False):
when = when or self.time
pos = len(self._queue)
for (ix, l) in enumerate(self._queue):
if l[0] == when:
l[1].append(agent)
return
if l[0] > when:
pos = ix
break
self._queue.insert(pos, (when, [agent]))
def step(self) -> None:
"""
Executes agents in order, one at a time. After each step,
an agent will signal when it wants to be scheduled next.
"""
if not self._queue:
return
now = self.time
next_time = self._queue[0][0]
if next_time > now:
self.time = next_time
return
bucket = self._queue.popleft()[1]
if self._shuffle:
self.model.random.shuffle(bucket)
for agent in bucket:
try:
when = agent.step() or 1
when += now
except DeadAgent:
continue
if when != INFINITY:
self._schedule(agent, when, replace=True)
self.steps += 1
if self._queue:
self.time = self._queue[0][0]
else:
self.time = INFINITY
class ShuffledTimedActivation(TimedActivation): class ShuffledTimedActivation(TimedActivation):
@ -211,3 +182,5 @@ class ShuffledTimedActivation(TimedActivation):
class OrderedTimedActivation(TimedActivation): class OrderedTimedActivation(TimedActivation):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, shuffle=False, **kwargs) super().__init__(*args, shuffle=False, **kwargs)

View File

@ -17,7 +17,7 @@ class TestAgents(TestCase):
"""The last step of a dead agent should return time.INFINITY""" """The last step of a dead agent should return time.INFINITY"""
d = Dead(unique_id=0, model=environment.Environment()) d = Dead(unique_id=0, model=environment.Environment())
ret = d.step() ret = d.step()
assert ret == stime.NEVER assert ret == stime.INFINITY
def test_die_raises_exception(self): def test_die_raises_exception(self):
"""A dead agent should raise an exception if it is stepped after death""" """A dead agent should raise an exception if it is stepped after death"""
@ -52,23 +52,25 @@ class TestAgents(TestCase):
def test_state_decorator(self): def test_state_decorator(self):
class MyAgent(agents.FSM): class MyAgent(agents.FSM):
run = 0 times_run = 0
@agents.state("original", default=True) @agents.state("original", default=True)
def root(self): def root(self):
self.run += 1
return self.other return self.other
@agents.state @agents.state
def other(self): def other(self):
self.run += 1 self.times_run += 1
e = environment.Environment() e = environment.Environment()
a = e.add_agent(MyAgent) a = e.add_agent(MyAgent)
e.step() e.step()
assert a.run == 1 assert a.times_run == 0
a.step() a.step()
print("DONE") assert a.times_run == 1
assert a.state_id == MyAgent.other.id
a.step()
assert a.times_run == 2
def test_broadcast(self): def test_broadcast(self):
""" """
@ -86,7 +88,7 @@ class TestAgents(TestCase):
except Exception as ex: except Exception as ex:
print(ex) print(ex)
while True: while True:
self.check_messages() self.process_messages()
yield yield
def on_receive(self, msg, sender=None): def on_receive(self, msg, sender=None):
@ -132,14 +134,14 @@ class TestAgents(TestCase):
while True: while True:
if pongs or not pings: # First agent, or anyone after that if pongs or not pings: # First agent, or anyone after that
pings.append(self.now) pings.append(self.now)
response = yield target.ask("PING") response = yield from target.ask("PING")
responses.append(response) responses.append(response)
else: else:
print("NOT sending ping") print("NOT sending ping")
print("Checking msgs") print("Checking msgs")
# Do not block if we have already received a PING # Do not block if we have already received a PING
if not self.check_messages(): if not self.process_messages():
yield self.received() yield from self.received()
print("done") print("done")
def on_receive(self, msg, sender=None): def on_receive(self, msg, sender=None):
@ -175,3 +177,199 @@ class TestAgents(TestCase):
assert ev[0].unique_id == 1 assert ev[0].unique_id == 1
null = list(e.agents(unique_ids=[0, 1], agent_class=agents.NetworkAgent)) null = list(e.agents(unique_ids=[0, 1], agent_class=agents.NetworkAgent))
assert not null assert not null
def test_agent_return(self):
'''
An agent should be able to cycle through different states and control when it
should be awaken.
'''
class TestAgent(agents.Agent):
@agents.state(default=True)
def one(self):
return self.two
@agents.state
def two(self):
return self.three.at(10)
@agents.state
def three(self):
return self.four.delay(1)
@agents.state
def four(self):
yield self.delay(2)
return self.five.delay(3)
@agents.state
def five(self):
return self.delay(1)
model = environment.Environment()
a = model.add_agent(TestAgent)
assert a.state_id == TestAgent.one.id
assert a.now == 0
model.step()
assert a.state_id == TestAgent.two.id
assert a.now == 1
model.step()
assert a.state_id == TestAgent.three.id
assert a.now == 10
model.step()
assert a.state_id == TestAgent.four.id
assert a.now == 11
model.step()
assert a.state_id == TestAgent.four.id
assert a.now == 13
model.step()
assert a.state_id == TestAgent.five.id
assert a.now == 16
model.step()
assert a.state_id == TestAgent.five.id
assert a.now == 17
def test_agent_async(self):
'''
Async functions should also be valid states.
'''
class TestAgent(agents.Agent):
@agents.state(default=True)
def one(self):
return self.two
@agents.state
def two(self):
return self.three.at(10)
@agents.state
def three(self):
return self.four.delay(1)
@agents.state
async def four(self):
await self.delay(2)
return self.five.delay(3)
@agents.state
def five(self):
return self.delay(1)
model = environment.Environment()
a = model.add_agent(TestAgent)
assert a.now == 0
assert a.state_id == TestAgent.one.id
model.step()
assert a.now == 1
assert a.state_id == TestAgent.two.id
model.step()
assert a.now == 10
assert a.state_id == TestAgent.three.id
model.step()
assert a.state_id == TestAgent.four.id
assert a.now == 11
model.step()
assert a.state_id == TestAgent.four.id
assert a.now == 13
model.step()
assert a.state_id == TestAgent.five.id
assert a.now == 16
model.step()
assert a.state_id == TestAgent.five.id
assert a.now == 17
def test_agent_return_step(self):
'''
The same result as the previous test should be achievable by manually
handling the agent state.
'''
class TestAgent(agents.Agent):
my_state = 1
my_count = 0
def step(self):
if self.my_state == 1:
self.my_state = 2
return None
elif self.my_state == 2:
self.my_state = 3
return self.at(10)
elif self.my_state == 3:
self.my_state = 4
self.my_count = 0
return self.delay(1)
elif self.my_state == 4:
self.my_count += 1
if self.my_count == 1:
return self.delay(2)
self.my_state = 5
return self.delay(3)
elif self.my_state == 5:
return self.delay(1)
model = environment.Environment()
a = model.add_agent(TestAgent)
assert a.my_state == 1
assert a.now == 0
model.step()
assert a.now == 1
assert a.my_state == 2
model.step()
assert a.now == 10
assert a.my_state == 3
model.step()
assert a.now == 11
assert a.my_state == 4
model.step()
assert a.now == 13
assert a.my_state == 4
model.step()
assert a.now == 16
assert a.my_state == 5
model.step()
assert a.now == 17
assert a.my_state == 5
def test_agent_return_step_async(self):
'''
The same result as the previous test should be achievable by manually
handling the agent state.
'''
class TestAgent(agents.Agent):
my_state = 1
async def step(self):
self.my_state = 2
await self.delay()
self.my_state = 3
await self.at(10)
self.my_state = 4
await self.delay(1)
await self.delay(2)
self.my_state = 5
await self.delay(3)
while True:
await self.delay(1)
model = environment.Environment()
a = model.add_agent(TestAgent)
assert a.my_state == 1
assert a.now == 0
model.step()
assert a.now == 1
assert a.my_state == 2
model.step()
assert a.now == 10
assert a.my_state == 3
model.step()
assert a.now == 11
assert a.my_state == 4
model.step()
assert a.now == 13
assert a.my_state == 4
model.step()
assert a.now == 16
assert a.my_state == 5
model.step()
assert a.now == 17
assert a.my_state == 5

View File

@ -29,15 +29,12 @@ class TestConfig(TestCase):
def test_torvalds_config(self): def test_torvalds_config(self):
sim = simulation.from_config(os.path.join(ROOT, "test_config.yml")) sim = simulation.from_config(os.path.join(ROOT, "test_config.yml"))
MAX_STEPS = 10 MAX_STEPS = 10
INTERVAL = 2
assert sim.interval == INTERVAL
assert sim.max_steps == MAX_STEPS assert sim.max_steps == MAX_STEPS
envs = sim.run() envs = sim.run()
assert len(envs) == 1 assert len(envs) == 1
env = envs[0] env = envs[0]
assert env.interval == 2
assert env.count_agents() == 3 assert env.count_agents() == 3
assert env.now == INTERVAL * MAX_STEPS assert env.now == MAX_STEPS
def make_example_test(path, cfg): def make_example_test(path, cfg):

View File

@ -2,4 +2,3 @@
source_file: "../examples/torvalds_sim.py" source_file: "../examples/torvalds_sim.py"
model: "TorvaldsEnv" model: "TorvaldsEnv"
max_steps: 10 max_steps: 10
interval: 2

View File

@ -88,7 +88,7 @@ class Exporters(TestCase):
parameters=dict( parameters=dict(
network_generator="complete_graph", network_generator="complete_graph",
network_params={"n": n_nodes}, network_params={"n": n_nodes},
agent_class="CounterModel", agent_class=agents.CounterModel,
agent_reporters={"times": "times"}, agent_reporters={"times": "times"},
), ),
max_time=max_time, max_time=max_time,

View File

@ -7,8 +7,6 @@ from functools import partial
from os.path import join from os.path import join
from soil import simulation, Environment, agents, network, serialization, utils, config, from_file from soil import simulation, Environment, agents, network, serialization, utils, config, from_file
from soil.time import Delta
from mesa import Agent as MesaAgent from mesa import Agent as MesaAgent
ROOT = os.path.abspath(os.path.dirname(__file__)) ROOT = os.path.abspath(os.path.dirname(__file__))
@ -114,7 +112,6 @@ class TestMain(TestCase):
def test_serialize_class(self): def test_serialize_class(self):
ser, name = serialization.serialize(agents.BaseAgent, known_modules=[]) ser, name = serialization.serialize(agents.BaseAgent, known_modules=[])
assert name == "soil.agents.BaseAgent" assert name == "soil.agents.BaseAgent"
assert ser == agents.BaseAgent
ser, name = serialization.serialize( ser, name = serialization.serialize(
agents.BaseAgent, agents.BaseAgent,
@ -123,11 +120,9 @@ class TestMain(TestCase):
], ],
) )
assert name == "BaseAgent" assert name == "BaseAgent"
assert ser == agents.BaseAgent
ser, name = serialization.serialize(CustomAgent) ser, name = serialization.serialize(CustomAgent)
assert name == "test_main.CustomAgent" assert name == "test_main.CustomAgent"
assert ser == CustomAgent
pickle.dumps(ser) pickle.dumps(ser)
def test_serialize_builtin_types(self): def test_serialize_builtin_types(self):
@ -168,7 +163,6 @@ class TestMain(TestCase):
def test_fsm(self): def test_fsm(self):
"""Basic state change""" """Basic state change"""
class ToggleAgent(agents.FSM): class ToggleAgent(agents.FSM):
@agents.default_state @agents.default_state
@agents.state @agents.state
@ -193,7 +187,7 @@ class TestMain(TestCase):
@agents.default_state @agents.default_state
@agents.state @agents.state
def ping(self): def ping(self):
return self.pong, 2 return self.pong.delay(2)
@agents.state @agents.state
def pong(self): def pong(self):
@ -203,7 +197,7 @@ class TestMain(TestCase):
when = a.step() when = a.step()
assert when == 2 assert when == 2
when = a.step() when = a.step()
assert when == Delta(a.interval) assert when == None
def test_load_sim(self): def test_load_sim(self):
"""Make sure at least one of the examples can be loaded""" """Make sure at least one of the examples can be loaded"""

View File

@ -4,26 +4,6 @@ from soil import time, agents, environment
class TestMain(TestCase): class TestMain(TestCase):
def test_cond(self):
"""
A condition should match a When if the concition is True
"""
t = time.Cond(lambda t: True)
f = time.Cond(lambda t: False)
for i in range(10):
w = time.When(i)
assert w == t
assert w is not f
def test_cond(self):
"""
Comparing a Cond to a Delta should always return False
"""
c = time.Cond(lambda t: False)
d = time.Delta(1)
assert c is not d
def test_cond_env(self): def test_cond_env(self):
""" """ """ """
@ -36,11 +16,12 @@ class TestMain(TestCase):
class CondAgent(agents.BaseAgent): class CondAgent(agents.BaseAgent):
def step(self): def step(self):
nonlocal done nonlocal done, times_started, times_asleep, times_awakened
times_started.append(self.now) times_started.append(self.now)
while True: while True:
times_asleep.append(self.now) times_asleep.append(self.now)
yield time.Cond(lambda agent: agent.now >= 10, delta=2) while self.now < 10:
yield self.delay(2)
times_awakened.append(self.now) times_awakened.append(self.now)
if self.now >= 10: if self.now >= 10:
break break
@ -57,7 +38,6 @@ class TestMain(TestCase):
assert times_started == [0] assert times_started == [0]
assert times_awakened == [10] assert times_awakened == [10]
assert done == [10] assert done == [10]
# The first time will produce the Cond.
assert env.schedule.steps == 6 assert env.schedule.steps == 6
assert len(times) == 6 assert len(times) == 6
@ -65,11 +45,10 @@ class TestMain(TestCase):
times.append(env.now) times.append(env.now)
env.step() env.step()
assert times == [0, 2, 4, 6, 8, 10, 11] assert times == [0, 2, 4, 6, 8, 10, 11, 12]
assert env.schedule.time == 13 assert env.schedule.time == 13
assert times_started == [0, 11] assert times_started == [0, 11, 12]
assert times_awakened == [10] assert times_awakened == [10, 11, 12]
assert done == [10] assert done == [10, 11, 12]
# Once more to yield the cond, another one to continue assert env.schedule.steps == 8
assert env.schedule.steps == 7 assert len(times) == 8
assert len(times) == 7