master
J. Fernando Sánchez 12 months ago
parent 5e93399d58
commit f49be3af68

@ -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.
* 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
* 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.
* 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
* 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
* `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`
@ -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
### 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.
## [0.20.8]

@ -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

@ -2,7 +2,7 @@ Welcome to Soil's documentation!
================================
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):.
@ -49,6 +49,7 @@ If you use Soil in your research, do not forget to cite this paper:
installation
Tutorial <tutorial/soil_tutorial>
notes_v1.0
soil-vs
..

@ -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

@ -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.
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)
- 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
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

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

@ -86,7 +86,7 @@ class Driver(Evented, FSM):
earnings = 0
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:
msg.driver = self
self.journey = msg
@ -95,15 +95,14 @@ class Driver(Evented, FSM):
"""If there are no more passengers, stop forever"""
c = self.count_agents(agent_class=Passenger)
self.debug(f"Passengers left {c}")
if not c:
self.die("No more passengers")
return c
@default_state
@state
def wandering(self):
@state(default=True)
async def wandering(self):
"""Move around the city until a journey is accepted"""
target = None
self.check_passengers()
if not self.check_passengers():
return self.die("No passengers left")
self.journey = None
while self.journey is None: # No potential journeys detected (see on_receive)
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.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
self.check_messages()
yield Delta(30) # Wait at least 30 seconds before checking again
self.process_messages()
await self.delay(30) # Wait at least 30 seconds before checking again
try:
# 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:
# No journey has been accepted. Try again
self.journey = None
@ -127,18 +127,19 @@ class Driver(Evented, FSM):
return self.driving
@state
def driving(self):
async def driving(self):
"""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}")
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}")
while self.move_towards(self.journey.destination, with_passenger=True):
yield
await self.delay()
self.info("Arrived at destination")
self.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
def move_towards(self, target, with_passenger=False):
@ -167,7 +168,7 @@ class Passenger(Evented, FSM):
pos = None
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):
self.journey = msg
@ -175,7 +176,7 @@ class Passenger(Evented, FSM):
@default_state
@state
def asking(self):
async def asking(self):
destination = (
self.random.randint(0, self.model.grid.height-1),
self.random.randint(0, self.model.grid.width-1),
@ -195,9 +196,9 @@ class Passenger(Evented, FSM):
while not self.journey:
self.debug(f"Waiting for responses at: { self.pos }")
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
yield self.received(expiration=expiration)
await self.received(expiration=expiration, delay=10)
except events.TimedOut:
self.info(f"Still no response. Waiting at: { self.pos }")
self.model.broadcast(
@ -208,13 +209,13 @@ class Passenger(Evented, FSM):
return self.driving_home
@state
def driving_home(self):
async def driving_home(self):
while (
self.pos[0] != self.journey.destination[0]
or self.pos[1] != self.journey.destination[1]
):
try:
yield self.received(timeout=60)
await self.received(timeout=60)
except events.TimedOut:
pass
@ -228,4 +229,4 @@ simulation = Simulation(name="RideHailing",
parameters=dict(n_passengers=2))
if __name__ == "__main__":
easy(simulation)
easy(simulation)

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

@ -1,5 +1,4 @@
from soil.agents import FSM, state, default_state
from soil.time import Delta
class Fibonacci(FSM):
@ -11,17 +10,17 @@ class Fibonacci(FSM):
def counting(self):
self.log("Stopping at {}".format(self.now))
prev, self["prev"] = self["prev"], max([self.now, self["prev"]])
return None, Delta(prev)
return self.delay(prev)
class Odds(FSM):
"""Agent that only executes in odd t_steps"""
@default_state
@state
@state(default=True)
def odds(self):
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
@ -35,7 +34,7 @@ class TimeoutsEnv(Environment):
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__":
sim.run(dump=False)
sim.run(dump=False)

@ -1,7 +1,7 @@
from soil import Simulation
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__":
sim.run()

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

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

@ -1,5 +1,6 @@
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.parameters import *
from soil.utils import int_seed
@ -39,8 +40,8 @@ class TerroristEnvironment(Environment):
HavenModel
], [self.ratio_civil, self.ratio_leader, self.ratio_training, self.ratio_haven])
def generator(self, *args, **kwargs):
return nx.random_geometric_graph(*args, **kwargs, seed=int_seed(self._seed))
def generator(self, *args, seed=None, **kwargs):
return nx.random_geometric_graph(*args, **kwargs, seed=seed or int_seed(self._seed))
class TerroristSpreadModel(FSM, Geo):
"""

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

@ -1 +1 @@
1.0.0rc2
1.0.0rc3

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

@ -25,6 +25,35 @@ from .. import serialization, network, utils, time, config
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):
def __new__(mcls, name, bases, namespace):
defaults = {}
@ -36,34 +65,23 @@ class MetaAgent(ABCMeta):
new_nmspc = {
"_defaults": defaults,
"_last_return": None,
"_last_except": None,
}
for attr, func in namespace.items():
if attr == "step" and inspect.isgeneratorfunction(func):
orig_func = func
new_nmspc["_coroutine"] = None
@wraps(func)
def func(self):
while True:
if not self._coroutine:
self._coroutine = orig_func(self)
try:
if self._last_except:
return self._coroutine.throw(self._last_except)
else:
return self._coroutine.send(self._last_return)
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
if attr == "step":
if inspect.isgeneratorfunction(func) or inspect.iscoroutinefunction(func):
func = decorate_generator_step(func, attr)
new_nmspc.update({
"_last_return": None,
"_last_except": None,
"_coroutine": None,
})
elif inspect.isasyncgenfunction(func):
raise ValueError("Illegal step function: {}. It probably mixes both async/await and yield".format(func))
elif inspect.isfunction(func):
func = decorate_normal_func(func, attr)
else:
raise ValueError("Illegal step function: {}".format(func))
new_nmspc[attr] = func
elif (
isinstance(func, types.FunctionType)
@ -74,9 +92,13 @@ class MetaAgent(ABCMeta):
new_nmspc[attr] = func
elif attr == "defaults":
defaults.update(func)
elif inspect.isfunction(func):
new_nmspc[attr] = func
else:
defaults[attr] = copy(func)
# Add attributes for their use in the decorated functions
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.
"""
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)
super().__init__(unique_id=unique_id, model=model)
@ -102,7 +124,6 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
self.alive = True
self.interval = interval or self.get("interval", 1)
logger = utils.logger.getChild(getattr(self.model, "id", self.model)).getChild(
self.name
)
@ -111,13 +132,18 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
if hasattr(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():
if not hasattr(self, k) or getattr(self, k) is None:
setattr(self, k, deepcopy(v))
for (k, v) in kwargs.items():
setattr(self, k, v)
if init:
self.init()
@ -189,11 +215,13 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
return it
def get(self, key, default=None):
if key in self:
return self[key]
elif key in self.model:
return self.model[key]
return default
try:
return getattr(self, key)
except AttributeError:
try:
return getattr(self.model, key)
except AttributeError:
return default
@property
def now(self):
@ -206,17 +234,18 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def die(self, msg=None):
if msg:
self.info("Agent dying:", msg)
self.debug(f"agent dying")
else:
self.debug(f"agent dying")
self.alive = False
try:
self.model.schedule.remove(self)
except KeyError:
pass
return time.NEVER
return time.Delay(time.INFINITY)
def step(self):
raise NotImplementedError("Agent must implement step method")
def _check_alive(self):
if not self.alive:
raise time.DeadAgent(self.unique_id)
@ -265,6 +294,12 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def __repr__(self):
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):
@ -450,8 +485,10 @@ def filter_agents(
state = state or dict()
state.update(kwargs)
for k, v in state.items():
f = filter(lambda agent: getattr(agent, k, None) == v, f)
for k, vs in state.items():
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:
f = islice(f, limit)
@ -658,15 +695,6 @@ from .SISaModel 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):
"""Create a new class from a template class and keyword arguments"""
return type(cls.__name__, (cls,), kwargs)

@ -1,42 +1,8 @@
from . import BaseAgent
from ..events import Message, Tell, Ask, TimedOut
from ..time import BaseCond
from functools import partial
from collections import deque
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})"
from types import coroutine
class EventedAgent(BaseAgent):
@ -48,30 +14,45 @@ class EventedAgent(BaseAgent):
def on_receive(self, *args, **kwargs):
pass
def received(self, *args, **kwargs):
return ReceivedOrTimeout(self, *args, **kwargs)
@coroutine
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):
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)
self._inbox.append(ask)
expiration = float("inf") if timeout is None else self.now + timeout
return ask.replied(expiration=expiration, **kwargs)
def check_messages(self):
changed = False
while self._inbox:
msg = self._inbox.popleft()
while self.now < expiration:
if ask.reply:
return ask.reply
yield self.delay(delay)
raise TimedOut("No reply received")
def process_messages(self):
valid = list()
for msg in self._inbox:
self._processed += 1
if msg.expired(self.now):
continue
changed = True
valid.append(msg)
reply = self.on_receive(msg.payload, sender=msg.sender)
if isinstance(msg, Ask):
msg.reply = reply
return changed
self._inbox.clear()
return valid
Evented = EventedAgent

@ -1,47 +1,69 @@
from . import MetaAgent, BaseAgent
from ..time import Delta
from .. import time
from types import coroutine
from functools import partial, wraps
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 decorator(func, name=None):
"""
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 when is the interval defined in the environment.
"""
if inspect.isgeneratorfunction(func):
orig_func = func
@wraps(func)
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
name = name or func.__name__
generator = inspect.isgeneratorfunction(func)
awaitable = inspect.iscoroutinefunction(func) or inspect.isasyncgen(func)
return UnboundState(func, name, default, generator, awaitable)
if callable(name):
return decorator(name)
@ -50,7 +72,7 @@ def state(name=None, default=False):
def default_state(func):
func.is_default = True
func.default = True
return func
@ -62,42 +84,45 @@ class MetaFSM(MetaAgent):
for i in bases:
if isinstance(i, MetaFSM):
for state_id, state in i._states.items():
if state.is_default:
if state.default:
default_state = state
states[state_id] = state
# Add new states
for attr, func in namespace.items():
if hasattr(func, "id"):
if func.is_default:
if isinstance(func, State):
if func.default:
default_state = func
states[func.id] = func
states[func.name] = func
namespace.update(
{
"_default_state": default_state,
"_state": default_state,
"_states": states,
}
)
return super(MetaFSM, mcls).__new__(
cls = super(MetaFSM, mcls).__new__(
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):
def __init__(self, init=True, **kwargs):
def __init__(self, init=True, state_id=None, **kwargs):
super().__init__(**kwargs, init=False)
if not hasattr(self, "state_id"):
if not self._default_state:
raise ValueError(
"No default state specified for {}".format(self.unique_id)
)
self.state_id = self._default_state.id
self._coroutine = None
self.default_interval = Delta(self.model.interval)
self._set_state(self.state_id)
if state_id is not None:
self._set_state(state_id)
# If more than "dead" state is defined, but no default state
if len(self._states) > 1 and not self._state:
raise ValueError(
f"No default state specified for {type(self)}({self.unique_id})"
)
for (k, v) in self._states.items():
setattr(self, k, v.bind(self))
if init:
self.init()
@ -105,44 +130,46 @@ class FSM(BaseAgent, metaclass=MetaFSM):
def states(cls):
return list(cls._states.keys())
def step(self):
self.debug(f"Agent {self.unique_id} @ state {self.state_id}")
@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):
self._check_alive()
next_state = self._states[self.state_id](self)
next_state = yield from self._state.step(self)
when = None
try:
next_state, *when = next_state
if not when:
when = None
elif len(when) == 1:
when = when[0]
else:
raise ValueError(
"Too many values returned. Only state (and time) allowed"
)
except TypeError:
pass
if next_state is not None:
self._set_state(next_state)
return when or self.default_interval
def _set_state(self, state, when=None):
if hasattr(state, "id"):
state = state.id
if state not in self._states:
next_state, when = next_state
except (TypeError, ValueError) as ex:
try:
self._set_state(next_state)
return None
except ValueError:
return next_state
self._set_state(next_state)
return when
def _set_state(self, state):
if state is None:
return
if isinstance(state, str):
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))
self.state_id = state
if when is not None:
self.model.schedule.add(self, when=when)
return state
self._state = state
def die(self, *args, **kwargs):
return self.dead, super().die(*args, **kwargs)
super().die(*args, **kwargs)
return self.dead.at(time.INFINITY)
@state
def dead(self):
return self.die()
return time.INFINITY

@ -6,7 +6,7 @@ class NetworkAgent(BaseAgent):
super().__init__(*args, init=False, **kwargs)
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:
nodes = self.random.choices(list(self.G.nodes), k=len(self.G))
for n_id in nodes:
@ -25,8 +25,6 @@ class NetworkAgent(BaseAgent):
def count_neighbors(self, state_id=None, **kwargs):
return len(self.get_neighbors(state_id=state_id, **kwargs))
if init:
self.init()
def iter_neighbors(self, **kwargs):
return self.iter_agents(limit_neighbors=True, **kwargs)

@ -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"])
#TODO implement reading from CSV and SQLITE
#TODO implement reading from CSV
def read_sql(fpath=None, name=None, include_agents=False):
if not (fpath is None) ^ (name is None):
raise ValueError("Specify either a path or a simulation name")

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

@ -16,7 +16,7 @@ import networkx as nx
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
@ -35,6 +35,7 @@ class BaseEnvironment(Model):
"""
collector_class = datacollection.SoilCollector
schedule_class = time.TimedActivation
def __new__(cls,
*args: Any,
@ -49,7 +50,6 @@ class BaseEnvironment(Model):
self = super().__new__(cls, *args, seed=seed, **kwargs)
self.dir_path = dir_path or os.getcwd()
collector_class = collector_class or cls.collector_class
collector_class = serialization.deserialize(collector_class)
self.datacollector = collector_class(
model_reporters=model_reporters,
agent_reporters=agent_reporters,
@ -60,7 +60,7 @@ class BaseEnvironment(Model):
if isinstance(v, property):
v = v.fget
if getattr(v, "add_to_report", False):
self.add_model_reporter(k, v)
self.add_model_reporter(k, k)
return self
@ -70,8 +70,8 @@ class BaseEnvironment(Model):
id="unnamed_env",
seed="default",
dir_path=None,
schedule_class=time.TimedActivation,
interval=1,
schedule=None,
schedule_class=None,
logger = None,
agents: Optional[Dict] = None,
collector_class: type = datacollection.SoilCollector,
@ -94,13 +94,11 @@ class BaseEnvironment(Model):
else:
self.logger = utils.logger.getChild(self.id)
if schedule_class is None:
schedule_class = time.TimedActivation
else:
schedule_class = serialization.deserialize(schedule_class)
self.interval = interval
self.schedule = schedule_class(self)
self.schedule = schedule
if schedule is None:
if schedule_class is None:
schedule_class = self.schedule_class
self.schedule = schedule_class(self)
for (k, v) in env_params.items():
self[k] = v
@ -161,7 +159,7 @@ class BaseEnvironment(Model):
if unique_id is None:
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)
return a
@ -204,14 +202,14 @@ class BaseEnvironment(Model):
def add_model_reporter(self, name, func=None):
if not func:
func = lambda env: getattr(env, name)
func = name
self.datacollector._new_model_reporter(name, func)
def add_agent_reporter(self, name, agent_type=None):
if agent_type:
reporter = lambda a: getattr(a, name) if isinstance(a, agent_type) else None
else:
reporter = lambda a: getattr(a, name, None)
def add_agent_reporter(self, name, reporter=None, agent_type=None):
if not agent_type and not reporter:
reporter = name
elif agent_type:
reporter = lambda a: reporter(a) if isinstance(a, agent_type) else None
self.datacollector._new_agent_reporter(name, reporter)
@classmethod
@ -278,8 +276,6 @@ class NetworkEnvironment(BaseEnvironment):
super().__init__(*args, **kwargs, init=False)
self.agent_class = agent_class
if agent_class:
self.agent_class = serialization.deserialize(agent_class)
if self.agent_class:
self.populate_network(self.agent_class)
self._check_agent_nodes()
@ -309,7 +305,15 @@ class NetworkEnvironment(BaseEnvironment):
elif path is not None:
topology = network.from_topology(path, dir_path=self.dir_path)
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:
raise ValueError("topology must be a networkx.Graph or a string, or network_generator must be provided")
self.G = topology

@ -1,4 +1,3 @@
from .time import BaseCond
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
@ -24,29 +23,9 @@ class Reply(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):
reply: Message = None
def replied(self, expiration=None):
return ReplyCond(self)
class Tell(Message):
pass

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

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

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

@ -1,7 +1,9 @@
from mesa.time import BaseScheduler
from queue import Empty
from heapq import heappush, heappop, heapreplace
from collections import deque
import math
import logging
from inspect import getsource
from numbers import Number
@ -13,119 +15,54 @@ from mesa import Agent as MesaAgent
INFINITY = float("inf")
class DeadAgent(Exception):
pass
class When:
def __init__(self, time):
if isinstance(time, When):
return time
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):
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 = 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
self.delta = float(delta)
def __repr__(self):
return self._msg or self.__class__.__name__
def __float__(self):
return self.delta
def __await__(self):
return (yield self.delta)
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 DeadAgent(Exception):
pass
class TimedActivation(BaseScheduler):
"""A scheduler which activates each agent when the agent requests.
class PQueueActivation(BaseScheduler):
"""
A scheduler which activates each agent with a delay returned by the agent's step method.
If no delay is returned, a default of 1 is used.
In each activation, each agent will update its 'next_time'.
"""
def __init__(self, *args, shuffle=True, **kwargs):
super().__init__(*args, **kwargs)
self._next = {}
self._queue = []
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.next_time = self.time
def add(self, agent: MesaAgent, when=None):
if when is None:
when = self.time
elif isinstance(when, When):
when = when.abs()
else:
when = float(when)
self._schedule(agent, None, when)
super().add(agent)
def _schedule(self, agent, condition=None, when=None, replace=False):
if condition:
if not when:
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
def _schedule(self, agent, when=None, replace=False):
if when is None:
when = self.time
if self._shuffle:
key = (when, self.model.random.random(), condition)
key = (when, self.model.random.random())
else:
key = (when, agent.unique_id, condition)
self._next[agent.unique_id] = key
key = (when, agent.unique_id)
if replace:
heapreplace(self._queue, (key, agent))
else:
@ -137,70 +74,104 @@ class TimedActivation(BaseScheduler):
an agent will signal when it wants to be scheduled next.
"""
self.logger.debug(f"Simulation step {self.time}")
if not self.model.running or self.time == INFINITY:
if self.time == INFINITY:
return
self.logger.debug(f"Queue length: %s", len(self._queue))
next_time = INFINITY
now = self.time
while self._queue:
((when, _id, cond), agent) = self._queue[0]
if when > self.time:
((when, _id), agent) = self._queue[0]
if when > now:
next_time = when
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:
returned = agent.step()
when = agent.step() or 1
when += now
except DeadAgent:
agent.alive = False
heappop(self._queue)
continue
# Check status for MESA agents
if not getattr(agent, "alive", True):
if when == INFINITY:
heappop(self._queue)
continue
if returned:
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._schedule(agent, when, replace=True)
self.steps += 1
if not self._queue:
self.time = next_time
if next_time == INFINITY:
self.model.running = False
self.time = INFINITY
return
next_time = self._queue[0][0][0]
if next_time < self.time:
raise Exception(
f"An agent has been scheduled for a time in the past, there is probably an error ({when} < {self.time})"
)
self.logger.debug("Updating time step: %s -> %s ", self.time, next_time)
class TimedActivation(BaseScheduler):
def __init__(self, *args, shuffle=True, **kwargs):
super().__init__(*args, **kwargs)
self._queue = deque()
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):
@ -211,3 +182,5 @@ class ShuffledTimedActivation(TimedActivation):
class OrderedTimedActivation(TimedActivation):
def __init__(self, *args, **kwargs):
super().__init__(*args, shuffle=False, **kwargs)

@ -17,7 +17,7 @@ class TestAgents(TestCase):
"""The last step of a dead agent should return time.INFINITY"""
d = Dead(unique_id=0, model=environment.Environment())
ret = d.step()
assert ret == stime.NEVER
assert ret == stime.INFINITY
def test_die_raises_exception(self):
"""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):
class MyAgent(agents.FSM):
run = 0
times_run = 0
@agents.state("original", default=True)
def root(self):
self.run += 1
return self.other
@agents.state
def other(self):
self.run += 1
self.times_run += 1
e = environment.Environment()
a = e.add_agent(MyAgent)
e.step()
assert a.run == 1
assert a.times_run == 0
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):
"""
@ -86,7 +88,7 @@ class TestAgents(TestCase):
except Exception as ex:
print(ex)
while True:
self.check_messages()
self.process_messages()
yield
def on_receive(self, msg, sender=None):
@ -132,14 +134,14 @@ class TestAgents(TestCase):
while True:
if pongs or not pings: # First agent, or anyone after that
pings.append(self.now)
response = yield target.ask("PING")
response = yield from target.ask("PING")
responses.append(response)
else:
print("NOT sending ping")
print("Checking msgs")
# Do not block if we have already received a PING
if not self.check_messages():
yield self.received()
if not self.process_messages():
yield from self.received()
print("done")
def on_receive(self, msg, sender=None):
@ -174,4 +176,200 @@ class TestAgents(TestCase):
assert len(ev) == 1
assert ev[0].unique_id == 1
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

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

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

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

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

@ -4,26 +4,6 @@ from soil import time, agents, environment
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):
""" """
@ -36,11 +16,12 @@ class TestMain(TestCase):
class CondAgent(agents.BaseAgent):
def step(self):
nonlocal done
nonlocal done, times_started, times_asleep, times_awakened
times_started.append(self.now)
while True:
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)
if self.now >= 10:
break
@ -57,7 +38,6 @@ class TestMain(TestCase):
assert times_started == [0]
assert times_awakened == [10]
assert done == [10]
# The first time will produce the Cond.
assert env.schedule.steps == 6
assert len(times) == 6
@ -65,11 +45,10 @@ class TestMain(TestCase):
times.append(env.now)
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 times_started == [0, 11]
assert times_awakened == [10]
assert done == [10]
# Once more to yield the cond, another one to continue
assert env.schedule.steps == 7
assert len(times) == 7
assert times_started == [0, 11, 12]
assert times_awakened == [10, 11, 12]
assert done == [10, 11, 12]
assert env.schedule.steps == 8
assert len(times) == 8

Loading…
Cancel
Save