1
0
mirror of https://github.com/gsi-upm/soil synced 2025-10-27 05:38:17 +00:00

Compare commits

...

8 Commits

Author SHA1 Message Date
J. Fernando Sánchez
a2fb25c160 Version 0.30.0rc2
* Fix CLI arguments not being used when easy is passed a simulation instance
* Docs for `examples/events_and_messages/cars.py`
2022-10-18 17:02:12 +02:00
J. Fernando Sánchez
5fcf610108 Version 0.30.0rc1 2022-10-18 15:02:05 +02:00
J. Fernando Sánchez
159c9a9077 Add events 2022-10-18 13:11:01 +02:00
J. Fernando Sánchez
3776c4e5c5 Refactor
* Removed references to `set_state`
* Split some functionality from `agents` into separate files (`fsm` and
`network_agents`)
* Rename `neighboring_agents` to `neighbors`
* Delete some spurious functions
2022-10-17 21:49:31 +02:00
J. Fernando Sánchez
880a9f2a1c black formatting 2022-10-17 20:23:57 +02:00
J. Fernando Sánchez
227fdf050e Fix conditionals 2022-10-17 19:29:39 +02:00
J. Fernando Sánchez
5d759d0072 Add conditional time values 2022-10-17 13:58:14 +02:00
J. Fernando Sánchez
77d08fc592 Agent step can be a generator 2022-10-17 08:58:51 +02:00
42 changed files with 1557 additions and 968 deletions

View File

@@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3 UNRELEASED]
## [0.30 UNRELEASED]
### Added
* 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>`
* Ability to run

View File

@@ -2,11 +2,12 @@ from networkx import Graph
import random
import networkx as nx
def mygenerator(n=5, n_edges=5):
'''
"""
Just a simple generator that creates a network with n nodes and
n_edges edges. Edges are assigned randomly, only avoiding self loops.
'''
"""
G = nx.Graph()
for i in range(n):
@@ -19,9 +20,3 @@ def mygenerator(n=5, n_edges=5):
n_out = random.choice(nodes)
G.add_edge(n_in, n_out)
return G

View File

@@ -2,34 +2,37 @@ from soil.agents import FSM, state, default_state
class Fibonacci(FSM):
'''Agent that only executes in t_steps that are Fibonacci numbers'''
"""Agent that only executes in t_steps that are Fibonacci numbers"""
defaults = {
'prev': 1
}
defaults = {"prev": 1}
@default_state
@state
def counting(self):
self.log('Stopping at {}'.format(self.now))
prev, self['prev'] = self['prev'], max([self.now, self['prev']])
self.log("Stopping at {}".format(self.now))
prev, self["prev"] = self["prev"], max([self.now, self["prev"]])
return None, self.env.timeout(prev)
class Odds(FSM):
'''Agent that only executes in odd t_steps'''
"""Agent that only executes in odd t_steps"""
@default_state
@state
def odds(self):
self.log('Stopping at {}'.format(self.now))
return None, self.env.timeout(1+self.now%2)
self.log("Stopping at {}".format(self.now))
return None, self.env.timeout(1 + self.now % 2)
if __name__ == '__main__':
if __name__ == "__main__":
from soil import Simulation
s = Simulation(network_agents=[{'ids': [0], 'agent_class': Fibonacci},
{'ids': [1], 'agent_class': Odds}],
network_params={"generator": "complete_graph", "n": 2},
max_time=100,
)
s = Simulation(
network_agents=[
{"ids": [0], "agent_class": Fibonacci},
{"ids": [1], "agent_class": Odds},
],
network_params={"generator": "complete_graph", "n": 2},
max_time=100,
)
s.run(dry_run=True)

View File

@@ -0,0 +1,7 @@
This example can be run like with command-line options, like this:
```bash
python cars.py --level DEBUG -e summary --csv
```
This will set the `CSV` (save the agent and model data to a CSV) and `summary` (print the a summary of the data to stdout) exporters, and set the log level to DEBUG.

View File

@@ -0,0 +1,205 @@
"""
This is an example of a simplified city, where there are Passengers and Drivers that can take those passengers
from their location to their desired location.
An example scenario could play like the following:
- Drivers start in the `wandering` state, where they wander around the city until they have been assigned a journey
- Passenger(1) tells every driver that it wants to request a Journey.
- Each driver receives the request.
If Driver(2) is interested in providing the Journey, it asks Passenger(1) to confirm that it accepts Driver(2)'s request
- When Passenger(1) accepts the request, two things happen:
- Passenger(1) changes its state to `driving_home`
- Driver(2) starts moving towards the origin of the Journey
- Once Driver(2) reaches the origin, it starts moving itself and Passenger(1) to the destination of the Journey
- When Driver(2) reaches the destination (carrying Passenger(1) along):
- Driver(2) starts wondering again
- Passenger(1) dies, and is removed from the simulation
- If there are no more passengers available in the simulation, Drivers die
"""
from __future__ import annotations
from soil import *
from soil import events
from mesa.space import MultiGrid
# More complex scenarios may use more than one type of message between objects.
# A common pattern is to use `enum.Enum` to represent state changes in a request.
@dataclass
class Journey:
"""
This represents a request for a journey. Passengers and drivers exchange this object.
A journey may have a driver assigned or not. If the driver has not been assigned, this
object is considered a "request for a journey".
"""
origin: (int, int)
destination: (int, int)
tip: float
passenger: Passenger
driver: Driver = None
class City(EventedEnvironment):
"""
An environment with a grid where drivers and passengers will be placed.
The number of drivers and riders is configurable through its parameters:
:param str n_cars: The total number of drivers to add
:param str n_passengers: The number of passengers in the simulation
:param list agents: Specific agents to use in the simulation. It overrides the `n_passengers`
and `n_cars` params.
:param int height: Height of the internal grid
:param int width: Width of the internal grid
"""
def __init__(self, *args, n_cars=1, n_passengers=10,
height=100, width=100, agents=None,
model_reporters=None,
**kwargs):
self.grid = MultiGrid(width=width, height=height, torus=False)
if agents is None:
agents = []
for i in range(n_cars):
agents.append({'agent_class': Driver})
for i in range(n_passengers):
agents.append({'agent_class': Passenger})
model_reporters = model_reporters or {'earnings': 'total_earnings', 'n_passengers': 'number_passengers'}
print('REPORTERS', model_reporters)
super().__init__(*args, agents=agents, model_reporters=model_reporters, **kwargs)
for agent in self.agents:
self.grid.place_agent(agent, (0, 0))
self.grid.move_to_empty(agent)
@property
def total_earnings(self):
return sum(d.earnings for d in self.agents(agent_class=Driver))
@property
def number_passengers(self):
return self.count_agents(agent_class=Passenger)
class Driver(Evented, FSM):
pos = None
journey = None
earnings = 0
def on_receive(self, msg, sender):
'''This is not a state. It will run (and block) every time check_messages is invoked'''
if self.journey is None and isinstance(msg, Journey) and msg.driver is None:
msg.driver = self
self.journey = msg
def check_passengers(self):
'''If there are no more passengers, stop forever'''
c = self.count_agents(agent_class=Passenger)
self.info(f"Passengers left {c}")
if not c:
self.die()
@default_state
@state
def wandering(self):
'''Move around the city until a journey is accepted'''
target = None
self.check_passengers()
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):
target = self.random.choice(self.model.grid.get_neighborhood(self.pos, moore=False))
self.check_passengers()
self.check_messages() # This will call on_receive behind the scenes, and the agent's status will be updated
yield Delta(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)
except events.TimedOut:
# No journey has been accepted. Try again
self.journey = None
return
return self.driving
@state
def driving(self):
'''The journey has been accepted. Pick them up and take them to their destination'''
while self.move_towards(self.journey.origin):
yield
while self.move_towards(self.journey.destination, with_passenger=True):
yield
self.earnings += self.journey.tip
self.check_passengers()
return self.wandering
def move_towards(self, target, with_passenger=False):
'''Move one cell at a time towards a target'''
self.info(f"Moving { self.pos } -> { target }")
if target[0] == self.pos[0] and target[1] == self.pos[1]:
return False
next_pos = [self.pos[0], self.pos[1]]
for idx in [0, 1]:
if self.pos[idx] < target[idx]:
next_pos[idx] += 1
break
if self.pos[idx] > target[idx]:
next_pos[idx] -= 1
break
self.model.grid.move_agent(self, tuple(next_pos))
if with_passenger:
self.journey.passenger.pos = self.pos # This could be communicated through messages
return True
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'''
if isinstance(msg, Journey):
self.journey = msg
return msg
@default_state
@state
def asking(self):
destination = (self.random.randint(0, self.model.grid.height), self.random.randint(0, self.model.grid.width))
self.journey = None
journey = Journey(origin=self.pos,
destination=destination,
tip=self.random.randint(10, 100),
passenger=self)
timeout = 60
expiration = self.now + timeout
self.model.broadcast(journey, ttl=timeout, sender=self, agent_class=Driver)
while not self.journey:
self.info(f"Passenger at: { self.pos }. Checking for responses.")
try:
yield self.received(expiration=expiration)
except events.TimedOut:
self.info(f"Passenger at: { self.pos }. Asking for journey.")
self.model.broadcast(journey, ttl=timeout, sender=self, agent_class=Driver)
expiration = self.now + timeout
self.check_messages()
return self.driving_home
@state
def driving_home(self):
while self.pos[0] != self.journey.destination[0] or self.pos[1] != self.journey.destination[1]:
yield self.received(timeout=60)
self.info("Got home safe!")
self.die()
simulation = Simulation(name='RideHailing', model_class=City, model_params={'n_passengers': 2})
if __name__ == "__main__":
with easy(simulation) as s:
s.run()

View File

@@ -14,16 +14,18 @@ def network_portrayal(env):
# The model ensures there is 0 or 1 agent per node
portrayal = dict()
wealths = {node_id: data['agent'].wealth for (node_id, data) in env.G.nodes(data=True)}
wealths = {
node_id: data["agent"].wealth for (node_id, data) in env.G.nodes(data=True)
}
portrayal["nodes"] = [
{
"id": node_id,
"size": 2*(wealth+1),
"size": 2 * (wealth + 1),
"color": "#CC0000" if wealth == 0 else "#007959",
# "color": "#CC0000",
"label": f"{node_id}: {wealth}",
} for (node_id, wealth) in wealths.items()
}
for (node_id, wealth) in wealths.items()
]
portrayal["edges"] = [
@@ -41,7 +43,7 @@ def gridPortrayal(agent):
:param agent: the agent in the simulation
:return: the portrayal dictionary
"""
color = max(10, min(agent.wealth*10, 100))
color = max(10, min(agent.wealth * 10, 100))
return {
"Shape": "rect",
"w": 1,
@@ -52,7 +54,7 @@ def gridPortrayal(agent):
"Text": agent.unique_id,
"x": agent.pos[0],
"y": agent.pos[1],
"Color": f"rgba(31, 10, 255, 0.{color})"
"Color": f"rgba(31, 10, 255, 0.{color})",
}
@@ -79,7 +81,7 @@ model_params = {
10,
1,
description="Grid height",
),
),
"width": UserSettableParameter(
"slider",
"width",
@@ -88,16 +90,20 @@ model_params = {
10,
1,
description="Grid width",
),
"agent_class": UserSettableParameter('choice', 'Agent class', value='MoneyAgent',
choices=['MoneyAgent', 'SocialMoneyAgent']),
),
"agent_class": UserSettableParameter(
"choice",
"Agent class",
value="MoneyAgent",
choices=["MoneyAgent", "SocialMoneyAgent"],
),
"generator": graph_generator,
}
canvas_element = CanvasGrid(gridPortrayal,
model_params["width"].value,
model_params["height"].value, 500, 500)
canvas_element = CanvasGrid(
gridPortrayal, model_params["width"].value, model_params["height"].value, 500, 500
)
server = ModularServer(

View File

@@ -1,9 +1,10 @@
'''
"""
This is an example that adds soil agents and environment in a normal
mesa workflow.
'''
"""
from mesa import Agent as MesaAgent
from mesa.space import MultiGrid
# from mesa.time import RandomActivation
from mesa.datacollection import DataCollector
from mesa.batchrunner import BatchRunner
@@ -12,12 +13,13 @@ import networkx as nx
from soil import NetworkAgent, Environment, serialization
def compute_gini(model):
agent_wealths = [agent.wealth for agent in model.agents]
x = sorted(agent_wealths)
N = len(list(model.agents))
B = sum( xi * (N-i) for i,xi in enumerate(x) ) / (N*sum(x))
return (1 + (1/N) - 2*B)
B = sum(xi * (N - i) for i, xi in enumerate(x)) / (N * sum(x))
return 1 + (1 / N) - 2 * B
class MoneyAgent(MesaAgent):
@@ -32,9 +34,8 @@ class MoneyAgent(MesaAgent):
def move(self):
possible_steps = self.model.grid.get_neighborhood(
self.pos,
moore=True,
include_center=False)
self.pos, moore=True, include_center=False
)
new_position = self.random.choice(possible_steps)
self.model.grid.move_agent(self, new_position)
@@ -57,7 +58,7 @@ class SocialMoneyAgent(NetworkAgent, MoneyAgent):
def give_money(self):
cellmates = set(self.model.grid.get_cell_list_contents([self.pos]))
friends = set(self.get_neighboring_agents())
friends = set(self.get_neighbors())
self.info("Trying to give money")
self.info("Cellmates: ", cellmates)
self.info("Friends: ", friends)
@@ -69,6 +70,7 @@ class SocialMoneyAgent(NetworkAgent, MoneyAgent):
other.wealth += 1
self.wealth -= 1
def graph_generator(n=5):
G = nx.Graph()
for ix in range(n):
@@ -78,16 +80,22 @@ def graph_generator(n=5):
class MoneyEnv(Environment):
"""A model with some number of agents."""
def __init__(self, width, height, N, generator=graph_generator,
agent_class=SocialMoneyAgent,
topology=None, **kwargs):
def __init__(
self,
width,
height,
N,
generator=graph_generator,
agent_class=SocialMoneyAgent,
topology=None,
**kwargs
):
generator = serialization.deserialize(generator)
agent_class = serialization.deserialize(agent_class, globs=globals())
topology = generator(n=N)
super().__init__(topology=topology,
N=N,
**kwargs)
super().__init__(topology=topology, N=N, **kwargs)
self.grid = MultiGrid(width, height, False)
self.populate_network(agent_class=agent_class)
@@ -99,26 +107,29 @@ class MoneyEnv(Environment):
self.grid.place_agent(agent, (x, y))
self.datacollector = DataCollector(
model_reporters={"Gini": compute_gini},
agent_reporters={"Wealth": "wealth"})
model_reporters={"Gini": compute_gini}, agent_reporters={"Wealth": "wealth"}
)
if __name__ == '__main__':
if __name__ == "__main__":
fixed_params = {"generator": nx.complete_graph,
"width": 10,
"network_agents": [{"agent_class": SocialMoneyAgent,
'weight': 1}],
"height": 10}
fixed_params = {
"generator": nx.complete_graph,
"width": 10,
"network_agents": [{"agent_class": SocialMoneyAgent, "weight": 1}],
"height": 10,
}
variable_params = {"N": range(10, 100, 10)}
batch_run = BatchRunner(MoneyEnv,
variable_parameters=variable_params,
fixed_parameters=fixed_params,
iterations=5,
max_steps=100,
model_reporters={"Gini": compute_gini})
batch_run = BatchRunner(
MoneyEnv,
variable_parameters=variable_params,
fixed_parameters=fixed_params,
iterations=5,
max_steps=100,
model_reporters={"Gini": compute_gini},
)
batch_run.run_all()
run_data = batch_run.get_model_vars_dataframe()

View File

@@ -4,24 +4,26 @@ from mesa.time import RandomActivation
from mesa.datacollection import DataCollector
from mesa.batchrunner import BatchRunner
def compute_gini(model):
agent_wealths = [agent.wealth for agent in model.schedule.agents]
x = sorted(agent_wealths)
N = model.num_agents
B = sum( xi * (N-i) for i,xi in enumerate(x) ) / (N*sum(x))
return (1 + (1/N) - 2*B)
B = sum(xi * (N - i) for i, xi in enumerate(x)) / (N * sum(x))
return 1 + (1 / N) - 2 * B
class MoneyAgent(Agent):
""" An agent with fixed initial wealth."""
"""An agent with fixed initial wealth."""
def __init__(self, unique_id, model):
super().__init__(unique_id, model)
self.wealth = 1
def move(self):
possible_steps = self.model.grid.get_neighborhood(
self.pos,
moore=True,
include_center=False)
self.pos, moore=True, include_center=False
)
new_position = self.random.choice(possible_steps)
self.model.grid.move_agent(self, new_position)
@@ -37,8 +39,10 @@ class MoneyAgent(Agent):
if self.wealth > 0:
self.give_money()
class MoneyModel(Model):
"""A model with some number of agents."""
def __init__(self, N, width, height):
self.num_agents = N
self.grid = MultiGrid(width, height, True)
@@ -55,29 +59,29 @@ class MoneyModel(Model):
self.grid.place_agent(a, (x, y))
self.datacollector = DataCollector(
model_reporters={"Gini": compute_gini},
agent_reporters={"Wealth": "wealth"})
model_reporters={"Gini": compute_gini}, agent_reporters={"Wealth": "wealth"}
)
def step(self):
self.datacollector.collect(self)
self.schedule.step()
if __name__ == '__main__':
if __name__ == "__main__":
fixed_params = {"width": 10,
"height": 10}
fixed_params = {"width": 10, "height": 10}
variable_params = {"N": range(10, 500, 10)}
batch_run = BatchRunner(MoneyModel,
variable_params,
fixed_params,
iterations=5,
max_steps=100,
model_reporters={"Gini": compute_gini})
batch_run = BatchRunner(
MoneyModel,
variable_params,
fixed_params,
iterations=5,
max_steps=100,
model_reporters={"Gini": compute_gini},
)
batch_run.run_all()
run_data = batch_run.get_model_vars_dataframe()
run_data.head()
print(run_data.Gini)

View File

@@ -3,84 +3,85 @@ import logging
class DumbViewer(FSM, NetworkAgent):
'''
"""
A viewer that gets infected via TV (if it has one) and tries to infect
its neighbors once it's infected.
'''
defaults = {
'prob_neighbor_spread': 0.5,
'prob_tv_spread': 0.1,
}
"""
prob_neighbor_spread = 0.5
prob_tv_spread = 0.1
has_been_infected = False
@default_state
@state
def neutral(self):
if self['has_tv']:
if self.prob(self.model['prob_tv_spread']):
if self["has_tv"]:
if self.prob(self.model["prob_tv_spread"]):
return self.infected
if self.has_been_infected:
return self.infected
@state
def infected(self):
for neighbor in self.get_neighboring_agents(state_id=self.neutral.id):
if self.prob(self.model['prob_neighbor_spread']):
for neighbor in self.get_neighbors(state_id=self.neutral.id):
if self.prob(self.model["prob_neighbor_spread"]):
neighbor.infect()
def infect(self):
'''
"""
This is not a state. It is a function that other agents can use to try to
infect this agent. DumbViewer always gets infected, but other agents like
HerdViewer might not become infected right away
'''
"""
self.set_state(self.infected)
self.has_been_infected = True
class HerdViewer(DumbViewer):
'''
"""
A viewer whose probability of infection depends on the state of its neighbors.
'''
"""
def infect(self):
'''Notice again that this is NOT a state. See DumbViewer.infect for reference'''
infected = self.count_neighboring_agents(state_id=self.infected.id)
total = self.count_neighboring_agents()
prob_infect = self.model['prob_neighbor_spread'] * infected/total
self.debug('prob_infect', prob_infect)
"""Notice again that this is NOT a state. See DumbViewer.infect for reference"""
infected = self.count_neighbors(state_id=self.infected.id)
total = self.count_neighbors()
prob_infect = self.model["prob_neighbor_spread"] * infected / total
self.debug("prob_infect", prob_infect)
if self.prob(prob_infect):
self.set_state(self.infected)
self.has_been_infected = True
class WiseViewer(HerdViewer):
'''
"""
A viewer that can change its mind.
'''
"""
defaults = {
'prob_neighbor_spread': 0.5,
'prob_neighbor_cure': 0.25,
'prob_tv_spread': 0.1,
"prob_neighbor_spread": 0.5,
"prob_neighbor_cure": 0.25,
"prob_tv_spread": 0.1,
}
@state
def cured(self):
prob_cure = self.model['prob_neighbor_cure']
for neighbor in self.get_neighboring_agents(state_id=self.infected.id):
prob_cure = self.model["prob_neighbor_cure"]
for neighbor in self.get_neighbors(state_id=self.infected.id):
if self.prob(prob_cure):
try:
neighbor.cure()
except AttributeError:
self.debug('Viewer {} cannot be cured'.format(neighbor.id))
self.debug("Viewer {} cannot be cured".format(neighbor.id))
def cure(self):
self.set_state(self.cured.id)
self.has_been_cured = True
@state
def infected(self):
cured = max(self.count_neighboring_agents(self.cured.id),
1.0)
infected = max(self.count_neighboring_agents(self.infected.id),
1.0)
prob_cure = self.model['prob_neighbor_cure'] * (cured/infected)
if self.has_been_cured:
return self.cured
cured = max(self.count_neighbors(self.cured.id), 1.0)
infected = max(self.count_neighbors(self.infected.id), 1.0)
prob_cure = self.model["prob_neighbor_cure"] * (cured / infected)
if self.prob(prob_cure):
return self.cured
return self.set_state(super().infected)

View File

@@ -1,6 +1,6 @@
'''
"""
Example of a fully programmatic simulation, without definition files.
'''
"""
from soil import Simulation, agents
from networkx import Graph
import logging
@@ -14,21 +14,22 @@ def mygenerator():
class MyAgent(agents.FSM):
@agents.default_state
@agents.state
def neutral(self):
self.debug('I am running')
self.debug("I am running")
if agents.prob(0.2):
self.info('This runs 2/10 times on average')
self.info("This runs 2/10 times on average")
s = Simulation(name='Programmatic',
network_params={'generator': mygenerator},
num_trials=1,
max_time=100,
agent_class=MyAgent,
dry_run=True)
s = Simulation(
name="Programmatic",
network_params={"generator": mygenerator},
num_trials=1,
max_time=100,
agent_class=MyAgent,
dry_run=True,
)
# By default, logging will only print WARNING logs (and above).

View File

@@ -5,7 +5,8 @@ import logging
class CityPubs(Environment):
'''Environment with Pubs'''
"""Environment with Pubs"""
level = logging.INFO
def __init__(self, *args, number_of_pubs=3, pub_capacity=10, **kwargs):
@@ -13,68 +14,70 @@ class CityPubs(Environment):
pubs = {}
for i in range(number_of_pubs):
newpub = {
'name': 'The awesome pub #{}'.format(i),
'open': True,
'capacity': pub_capacity,
'occupancy': 0,
"name": "The awesome pub #{}".format(i),
"open": True,
"capacity": pub_capacity,
"occupancy": 0,
}
pubs[newpub['name']] = newpub
self['pubs'] = pubs
pubs[newpub["name"]] = newpub
self["pubs"] = pubs
def enter(self, pub_id, *nodes):
'''Agents will try to enter. The pub checks if it is possible'''
"""Agents will try to enter. The pub checks if it is possible"""
try:
pub = self['pubs'][pub_id]
pub = self["pubs"][pub_id]
except KeyError:
raise ValueError('Pub {} is not available'.format(pub_id))
if not pub['open'] or (pub['capacity'] < (len(nodes) + pub['occupancy'])):
raise ValueError("Pub {} is not available".format(pub_id))
if not pub["open"] or (pub["capacity"] < (len(nodes) + pub["occupancy"])):
return False
pub['occupancy'] += len(nodes)
pub["occupancy"] += len(nodes)
for node in nodes:
node['pub'] = pub_id
node["pub"] = pub_id
return True
def available_pubs(self):
for pub in self['pubs'].values():
if pub['open'] and (pub['occupancy'] < pub['capacity']):
yield pub['name']
for pub in self["pubs"].values():
if pub["open"] and (pub["occupancy"] < pub["capacity"]):
yield pub["name"]
def exit(self, pub_id, *node_ids):
'''Agents will notify the pub they want to leave'''
"""Agents will notify the pub they want to leave"""
try:
pub = self['pubs'][pub_id]
pub = self["pubs"][pub_id]
except KeyError:
raise ValueError('Pub {} is not available'.format(pub_id))
raise ValueError("Pub {} is not available".format(pub_id))
for node_id in node_ids:
node = self.get_agent(node_id)
if pub_id == node['pub']:
del node['pub']
pub['occupancy'] -= 1
if pub_id == node["pub"]:
del node["pub"]
pub["occupancy"] -= 1
class Patron(FSM, NetworkAgent):
'''Agent that looks for friends to drink with. It will do three things:
1) Look for other patrons to drink with
2) Look for a bar where the agent and other agents in the same group can get in.
3) While in the bar, patrons only drink, until they get drunk and taken home.
'''
"""Agent that looks for friends to drink with. It will do three things:
1) Look for other patrons to drink with
2) Look for a bar where the agent and other agents in the same group can get in.
3) While in the bar, patrons only drink, until they get drunk and taken home.
"""
level = logging.DEBUG
pub = None
drunk = False
pints = 0
max_pints = 3
kicked_out = False
@default_state
@state
def looking_for_friends(self):
'''Look for friends to drink with'''
self.info('I am looking for friends')
available_friends = list(self.get_agents(drunk=False,
pub=None,
state_id=self.looking_for_friends.id))
"""Look for friends to drink with"""
self.info("I am looking for friends")
available_friends = list(
self.get_agents(drunk=False, pub=None, state_id=self.looking_for_friends.id)
)
if not available_friends:
self.info('Life sucks and I\'m alone!')
self.info("Life sucks and I'm alone!")
return self.at_home
befriended = self.try_friends(available_friends)
if befriended:
@@ -82,91 +85,91 @@ class Patron(FSM, NetworkAgent):
@state
def looking_for_pub(self):
'''Look for a pub that accepts me and my friends'''
if self['pub'] != None:
"""Look for a pub that accepts me and my friends"""
if self["pub"] != None:
return self.sober_in_pub
self.debug('I am looking for a pub')
group = list(self.get_neighboring_agents())
self.debug("I am looking for a pub")
group = list(self.get_neighbors())
for pub in self.model.available_pubs():
self.debug('We\'re trying to get into {}: total: {}'.format(pub, len(group)))
self.debug("We're trying to get into {}: total: {}".format(pub, len(group)))
if self.model.enter(pub, self, *group):
self.info('We\'re all {} getting in {}!'.format(len(group), pub))
self.info("We're all {} getting in {}!".format(len(group), pub))
return self.sober_in_pub
@state
def sober_in_pub(self):
'''Drink up.'''
"""Drink up."""
self.drink()
if self['pints'] > self['max_pints']:
if self["pints"] > self["max_pints"]:
return self.drunk_in_pub
@state
def drunk_in_pub(self):
'''I'm out. Take me home!'''
self.info('I\'m so drunk. Take me home!')
self['drunk'] = True
pass # out drunk
"""I'm out. Take me home!"""
self.info("I'm so drunk. Take me home!")
self["drunk"] = True
if self.kicked_out:
return self.at_home
pass # out drun
@state
def at_home(self):
'''The end'''
"""The end"""
others = self.get_agents(state_id=Patron.at_home.id, limit_neighbors=True)
self.debug('I\'m home. Just like {} of my friends'.format(len(others)))
self.debug("I'm home. Just like {} of my friends".format(len(others)))
def drink(self):
self['pints'] += 1
self.debug('Cheers to that')
self["pints"] += 1
self.debug("Cheers to that")
def kick_out(self):
self.set_state(self.at_home)
self.kicked_out = True
def befriend(self, other_agent, force=False):
'''
"""
Try to become friends with another agent. The chances of
success depend on both agents' openness.
'''
if force or self['openness'] > self.random.random():
"""
if force or self["openness"] > self.random.random():
self.add_edge(self, other_agent)
self.info('Made some friend {}'.format(other_agent))
self.info("Made some friend {}".format(other_agent))
return True
return False
def try_friends(self, others):
''' Look for random agents around me and try to befriend them'''
"""Look for random agents around me and try to befriend them"""
befriended = False
k = int(10*self['openness'])
k = int(10 * self["openness"])
self.random.shuffle(others)
for friend in islice(others, k): # random.choice >= 3.7
if friend == self:
continue
if friend.befriend(self):
self.befriend(friend, force=True)
self.debug('Hooray! new friend: {}'.format(friend.id))
self.debug("Hooray! new friend: {}".format(friend.id))
befriended = True
else:
self.debug('{} does not want to be friends'.format(friend.id))
self.debug("{} does not want to be friends".format(friend.id))
return befriended
class Police(FSM):
'''Simple agent to take drunk people out of pubs.'''
"""Simple agent to take drunk people out of pubs."""
level = logging.INFO
@default_state
@state
def patrol(self):
drunksters = list(self.get_agents(drunk=True,
state_id=Patron.drunk_in_pub.id))
drunksters = list(self.get_agents(drunk=True, state_id=Patron.drunk_in_pub.id))
for drunk in drunksters:
self.info('Kicking out the trash: {}'.format(drunk.id))
self.info("Kicking out the trash: {}".format(drunk.id))
drunk.kick_out()
else:
self.info('No trash to take out. Too bad.')
self.info("No trash to take out. Too bad.")
if __name__ == '__main__':
if __name__ == "__main__":
from soil import simulation
simulation.run_from_config('pubcrawl.yml',
dry_run=True,
dump=None,
parallel=False)
simulation.run_from_config("pubcrawl.yml", dry_run=True, dump=None, parallel=False)

View File

@@ -2,3 +2,13 @@ There are two similar implementations of this simulation.
- `basic`. Using simple primites
- `improved`. Using more advanced features such as the `time` module to avoid unnecessary computations (i.e., skip steps), and generator functions.
The examples can be run directly in the terminal, and they accept command like arguments.
For example, to enable the CSV exporter and the Summary exporter, while setting `max_time` to `100` and `seed` to `CustomSeed`:
```
python rabbit_agents.py --set max_time=100 --csv -e summary --set 'seed="CustomSeed"'
```
To learn more about how this functionality works, check out the `soil.easy` function.

View File

@@ -1,13 +1,10 @@
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment
from soil.time import Delta
from enum import Enum
from collections import Counter
import logging
import math
class RabbitEnv(Environment):
@property
def num_rabbits(self):
return self.count_agents(agent_class=Rabbit)
@@ -21,8 +18,7 @@ class RabbitEnv(Environment):
return self.count_agents(agent_class=Female)
class Rabbit(FSM, NetworkAgent):
class Rabbit(NetworkAgent, FSM):
sexual_maturity = 30
life_expectancy = 300
@@ -30,7 +26,7 @@ class Rabbit(FSM, NetworkAgent):
@default_state
@state
def newborn(self):
self.info('I am a newborn.')
self.info("I am a newborn.")
self.age = 0
self.offspring = 0
return self.youngling
@@ -39,7 +35,7 @@ class Rabbit(FSM, NetworkAgent):
def youngling(self):
self.age += 1
if self.age >= self.sexual_maturity:
self.info(f'I am fertile! My age is {self.age}')
self.info(f"I am fertile! My age is {self.age}")
return self.fertile
@state
@@ -63,17 +59,18 @@ class Male(Rabbit):
return self.dead
# Males try to mate
for f in self.model.agents(agent_class=Female,
state_id=Female.fertile.id,
limit=self.max_females):
self.debug('FOUND A FEMALE: ', repr(f), self.mating_prob)
if self.prob(self['mating_prob']):
for f in self.model.agents(
agent_class=Female, state_id=Female.fertile.id, limit=self.max_females
):
self.debug("FOUND A FEMALE: ", repr(f), self.mating_prob)
if self.prob(self["mating_prob"]):
f.impregnate(self)
break # Take a break
class Female(Rabbit):
gestation = 30
gestation = 10
pregnancy = -1
@state
def fertile(self):
@@ -81,70 +78,73 @@ class Female(Rabbit):
self.age += 1
if self.age > self.life_expectancy:
return self.dead
if self.pregnancy >= 0:
return self.pregnant
def impregnate(self, male):
self.info(f'{repr(male)} impregnating female {repr(self)}')
self.info(f"impregnated by {repr(male)}")
self.mate = male
self.pregnancy = -1
self.set_state(self.pregnant, when=self.now)
self.number_of_babies = int(8+4*self.random.random())
self.pregnancy = 0
self.number_of_babies = int(8 + 4 * self.random.random())
@state
def pregnant(self):
self.debug('I am pregnant')
self.info("I am pregnant")
self.age += 1
self.pregnancy += 1
if self.prob(self.age / self.life_expectancy):
if self.age >= self.life_expectancy:
return self.die()
if self.pregnancy >= self.gestation:
self.info('Having {} babies'.format(self.number_of_babies))
for i in range(self.number_of_babies):
state = {}
agent_class = self.random.choice([Male, Female])
child = self.model.add_node(agent_class=agent_class,
**state)
child.add_edge(self)
try:
child.add_edge(self.mate)
self.model.agents[self.mate].offspring += 1
except ValueError:
self.debug('The father has passed away')
if self.pregnancy < self.gestation:
self.pregnancy += 1
return
self.offspring += 1
self.mate = None
return self.fertile
self.info("Having {} babies".format(self.number_of_babies))
for i in range(self.number_of_babies):
state = {}
agent_class = self.random.choice([Male, Female])
child = self.model.add_node(agent_class=agent_class, **state)
child.add_edge(self)
try:
child.add_edge(self.mate)
self.model.agents[self.mate].offspring += 1
except ValueError:
self.debug("The father has passed away")
@state
def dead(self):
super().dead()
if 'pregnancy' in self and self['pregnancy'] > -1:
self.info('A mother has died carrying a baby!!')
self.offspring += 1
self.mate = None
self.pregnancy = -1
return self.fertile
def die(self):
if "pregnancy" in self and self["pregnancy"] > -1:
self.info("A mother has died carrying a baby!!")
return super().die()
class RandomAccident(BaseAgent):
level = logging.INFO
def step(self):
rabbits_alive = self.model.G.number_of_nodes()
if not rabbits_alive:
return self.die()
prob_death = self.model.get('prob_death', 1e-100)*math.floor(math.log10(max(1, rabbits_alive)))
self.debug('Killing some rabbits with prob={}!'.format(prob_death))
prob_death = self.model.get("prob_death", 1e-100) * math.floor(
math.log10(max(1, rabbits_alive))
)
self.debug("Killing some rabbits with prob={}!".format(prob_death))
for i in self.iter_agents(agent_class=Rabbit):
if i.state_id == i.dead.id:
continue
if self.prob(prob_death):
self.info('I killed a rabbit: {}'.format(i.id))
self.info("I killed a rabbit: {}".format(i.id))
rabbits_alive -= 1
i.set_state(i.dead)
self.debug('Rabbits alive: {}'.format(rabbits_alive))
i.die()
self.debug("Rabbits alive: {}".format(rabbits_alive))
if __name__ == '__main__':
if __name__ == "__main__":
from soil import easy
sim = easy('rabbits.yml')
sim.run()
with easy("rabbits.yml") as sim:
sim.run()

View File

@@ -1,130 +1,157 @@
from soil.agents import FSM, state, default_state, BaseAgent, NetworkAgent
from soil.time import Delta, When, NEVER
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment
from soil.time import Delta
from enum import Enum
from collections import Counter
import logging
import math
class RabbitModel(FSM, NetworkAgent):
class RabbitEnv(Environment):
@property
def num_rabbits(self):
return self.count_agents(agent_class=Rabbit)
mating_prob = 0.005
offspring = 0
@property
def num_males(self):
return self.count_agents(agent_class=Male)
@property
def num_females(self):
return self.count_agents(agent_class=Female)
class Rabbit(FSM, NetworkAgent):
sexual_maturity = 30
life_expectancy = 300
birth = None
sexual_maturity = 3
life_expectancy = 30
@property
def age(self):
if self.birth is None:
return None
return self.now - self.birth
@default_state
@state
def newborn(self):
self.info("I am a newborn.")
self.birth = self.now
self.info(f'I am a newborn.')
self.model['rabbits_alive'] = self.model.get('rabbits_alive', 0) + 1
self.offspring = 0
return self.youngling, Delta(self.sexual_maturity - self.age)
# Here we can skip the `youngling` state by using a coroutine/generator.
while self.age < self.sexual_maturity:
interval = self.sexual_maturity - self.age
yield Delta(interval)
self.info(f'I am fertile! My age is {self.age}')
return self.fertile
@property
def age(self):
return self.now - self.birth
@state
def youngling(self):
if self.age >= self.sexual_maturity:
self.info(f"I am fertile! My age is {self.age}")
return self.fertile
@state
def fertile(self):
raise Exception("Each subclass should define its fertile state")
def step(self):
super().step()
if self.prob(self.age / self.life_expectancy):
return self.die()
@state
def dead(self):
self.die()
class Male(RabbitModel):
class Male(Rabbit):
max_females = 5
mating_prob = 0.001
@state
def fertile(self):
# Males try to mate
for f in self.model.agents(agent_class=Female,
state_id=Female.fertile.id,
limit=self.max_females):
self.debug('Found a female:', repr(f))
if self.prob(self['mating_prob']):
f.impregnate(self)
break # Take a break, don't try to impregnate the rest
class Female(RabbitModel):
due_date = None
age_of_pregnancy = None
gestation = 10
mate = None
@state
def fertile(self):
return self.fertile, NEVER
@state
def pregnant(self):
self.info('I am pregnant')
if self.age > self.life_expectancy:
return self.dead
self.due_date = self.now + self.gestation
# Males try to mate
for f in self.model.agents(
agent_class=Female, state_id=Female.fertile.id, limit=self.max_females
):
self.debug("FOUND A FEMALE: ", repr(f), self.mating_prob)
if self.prob(self["mating_prob"]):
f.impregnate(self)
break # Do not try to impregnate other females
number_of_babies = int(8+4*self.random.random())
while self.now < self.due_date:
yield When(self.due_date)
self.info('Having {} babies'.format(number_of_babies))
for i in range(number_of_babies):
agent_class = self.random.choice([Male, Female])
child = self.model.add_node(agent_class=agent_class,
topology=self.topology)
self.model.add_edge(self, child)
self.model.add_edge(self.mate, child)
self.offspring += 1
self.model.agents[self.mate].offspring += 1
self.mate = None
self.due_date = None
return self.fertile
class Female(Rabbit):
gestation = 10
conception = None
@state
def dead(self):
super().dead()
if self.due_date is not None:
self.info('A mother has died carrying a baby!!')
def fertile(self):
# Just wait for a Male
if self.age > self.life_expectancy:
return self.dead
if self.conception is not None:
return self.pregnant
@property
def pregnancy(self):
if self.conception is None:
return None
return self.now - self.conception
def impregnate(self, male):
self.info(f'{repr(male)} impregnating female {repr(self)}')
self.info(f"impregnated by {repr(male)}")
self.mate = male
self.set_state(self.pregnant, when=self.now)
self.conception = self.now
self.number_of_babies = int(8 + 4 * self.random.random())
@state
def pregnant(self):
self.debug("I am pregnant")
if self.age > self.life_expectancy:
self.info("Dying before giving birth")
return self.die()
if self.pregnancy >= self.gestation:
self.info("Having {} babies".format(self.number_of_babies))
for i in range(self.number_of_babies):
state = {}
agent_class = self.random.choice([Male, Female])
child = self.model.add_node(agent_class=agent_class, **state)
child.add_edge(self)
if self.mate:
child.add_edge(self.mate)
self.mate.offspring += 1
else:
self.debug("The father has passed away")
self.offspring += 1
self.mate = None
return self.fertile
def die(self):
if self.pregnancy is not None:
self.info("A mother has died carrying a baby!!")
return super().die()
class RandomAccident(BaseAgent):
level = logging.INFO
def step(self):
rabbits_total = self.model.topology.number_of_nodes()
if 'rabbits_alive' not in self.model:
self.model['rabbits_alive'] = 0
rabbits_alive = self.model.get('rabbits_alive', rabbits_total)
prob_death = self.model.get('prob_death', 1e-100)*math.floor(math.log10(max(1, rabbits_alive)))
self.debug('Killing some rabbits with prob={}!'.format(prob_death))
for i in self.model.network_agents:
if i.state.id == i.dead.id:
rabbits_alive = self.model.G.number_of_nodes()
if not rabbits_alive:
return self.die()
prob_death = self.model.get("prob_death", 1e-100) * math.floor(
math.log10(max(1, rabbits_alive))
)
self.debug("Killing some rabbits with prob={}!".format(prob_death))
for i in self.iter_agents(agent_class=Rabbit):
if i.state_id == i.dead.id:
continue
if self.prob(prob_death):
self.info('I killed a rabbit: {}'.format(i.id))
rabbits_alive = self.model['rabbits_alive'] = rabbits_alive -1
i.set_state(i.dead)
self.debug('Rabbits alive: {}/{}'.format(rabbits_alive, rabbits_total))
if self.model.count_agents(state_id=RabbitModel.dead.id) == self.model.topology.number_of_nodes():
self.die()
self.info("I killed a rabbit: {}".format(i.id))
rabbits_alive -= 1
i.die()
self.debug("Rabbits alive: {}".format(rabbits_alive))
if __name__ == "__main__":
from soil import easy
with easy("rabbits.yml") as sim:
sim.run()

View File

@@ -7,11 +7,10 @@ description: null
group: null
interval: 1.0
max_time: 100
model_class: soil.environment.Environment
model_class: rabbit_agents.RabbitEnv
model_params:
agents:
topology: true
agent_class: rabbit_agents.RabbitModel
distribution:
- agent_class: rabbit_agents.Male
weight: 1
@@ -34,5 +33,10 @@ model_params:
nodes:
- id: 1
- id: 0
model_reporters:
num_males: 'num_males'
num_females: 'num_females'
num_rabbits: |
py:lambda env: env.num_males + env.num_females
extra:
visualization_params: {}

View File

@@ -1,42 +1,43 @@
'''
"""
Example of setting a
Example of a fully programmatic simulation, without definition files.
'''
"""
from soil import Simulation, agents
from soil.time import Delta
class MyAgent(agents.FSM):
'''
"""
An agent that first does a ping
'''
"""
defaults = {'pong_counts': 2}
defaults = {"pong_counts": 2}
@agents.default_state
@agents.state
def ping(self):
self.info('Ping')
return self.pong, Delta(self.random.expovariate(1/16))
self.info("Ping")
return self.pong, Delta(self.random.expovariate(1 / 16))
@agents.state
def pong(self):
self.info('Pong')
self.info("Pong")
self.pong_counts -= 1
self.info(str(self.pong_counts))
if self.pong_counts < 1:
return self.die()
return None, Delta(self.random.expovariate(1/16))
return None, Delta(self.random.expovariate(1 / 16))
s = Simulation(name='Programmatic',
network_agents=[{'agent_class': MyAgent, 'id': 0}],
topology={'nodes': [{'id': 0}], 'links': []},
num_trials=1,
max_time=100,
agent_class=MyAgent,
dry_run=True)
s = Simulation(
name="Programmatic",
network_agents=[{"agent_class": MyAgent, "id": 0}],
topology={"nodes": [{"id": 0}], "links": []},
num_trials=1,
max_time=100,
agent_class=MyAgent,
dry_run=True,
)
envs = s.run()

View File

@@ -20,56 +20,83 @@ class TerroristSpreadModel(FSM, Geo):
def __init__(self, model=None, unique_id=0, state=()):
super().__init__(model=model, unique_id=unique_id, state=state)
self.information_spread_intensity = model.environment_params['information_spread_intensity']
self.terrorist_additional_influence = model.environment_params['terrorist_additional_influence']
self.prob_interaction = model.environment_params['prob_interaction']
self.information_spread_intensity = model.environment_params[
"information_spread_intensity"
]
self.terrorist_additional_influence = model.environment_params[
"terrorist_additional_influence"
]
self.prob_interaction = model.environment_params["prob_interaction"]
if self['id'] == self.civilian.id: # Civilian
if self["id"] == self.civilian.id: # Civilian
self.mean_belief = self.random.uniform(0.00, 0.5)
elif self['id'] == self.terrorist.id: # Terrorist
elif self["id"] == self.terrorist.id: # Terrorist
self.mean_belief = self.random.uniform(0.8, 1.00)
elif self['id'] == self.leader.id: # Leader
elif self["id"] == self.leader.id: # Leader
self.mean_belief = 1.00
else:
raise Exception('Invalid state id: {}'.format(self['id']))
if 'min_vulnerability' in model.environment_params:
self.vulnerability = self.random.uniform( model.environment_params['min_vulnerability'], model.environment_params['max_vulnerability'] )
else :
self.vulnerability = self.random.uniform( 0, model.environment_params['max_vulnerability'] )
raise Exception("Invalid state id: {}".format(self["id"]))
if "min_vulnerability" in model.environment_params:
self.vulnerability = self.random.uniform(
model.environment_params["min_vulnerability"],
model.environment_params["max_vulnerability"],
)
else:
self.vulnerability = self.random.uniform(
0, model.environment_params["max_vulnerability"]
)
@state
def civilian(self):
neighbours = list(self.get_neighboring_agents(agent_class=TerroristSpreadModel))
neighbours = list(self.get_neighbors(agent_class=TerroristSpreadModel))
if len(neighbours) > 0:
# Only interact with some of the neighbors
interactions = list(n for n in neighbours if self.random.random() <= self.prob_interaction)
influence = sum( self.degree(i) for i in interactions )
mean_belief = sum( i.mean_belief * self.degree(i) / influence for i in interactions )
mean_belief = mean_belief * self.information_spread_intensity + self.mean_belief * ( 1 - self.information_spread_intensity )
self.mean_belief = mean_belief * self.vulnerability + self.mean_belief * ( 1 - self.vulnerability )
interactions = list(
n for n in neighbours if self.random.random() <= self.prob_interaction
)
influence = sum(self.degree(i) for i in interactions)
mean_belief = sum(
i.mean_belief * self.degree(i) / influence for i in interactions
)
mean_belief = (
mean_belief * self.information_spread_intensity
+ self.mean_belief * (1 - self.information_spread_intensity)
)
self.mean_belief = mean_belief * self.vulnerability + self.mean_belief * (
1 - self.vulnerability
)
if self.mean_belief >= 0.8:
return self.terrorist
@state
def leader(self):
self.mean_belief = self.mean_belief ** ( 1 - self.terrorist_additional_influence )
for neighbour in self.get_neighboring_agents(state_id=[self.terrorist.id, self.leader.id]):
self.mean_belief = self.mean_belief ** (1 - self.terrorist_additional_influence)
for neighbour in self.get_neighbors(
state_id=[self.terrorist.id, self.leader.id]
):
if self.betweenness(neighbour) > self.betweenness(self):
return self.terrorist
@state
def terrorist(self):
neighbours = self.get_agents(state_id=[self.terrorist.id, self.leader.id],
agent_class=TerroristSpreadModel,
limit_neighbors=True)
neighbours = self.get_agents(
state_id=[self.terrorist.id, self.leader.id],
agent_class=TerroristSpreadModel,
limit_neighbors=True,
)
if len(neighbours) > 0:
influence = sum( self.degree(n) for n in neighbours )
mean_belief = sum( n.mean_belief * self.degree(n) / influence for n in neighbours )
mean_belief = mean_belief * self.vulnerability + self.mean_belief * ( 1 - self.vulnerability )
self.mean_belief = self.mean_belief ** ( 1 - self.terrorist_additional_influence )
influence = sum(self.degree(n) for n in neighbours)
mean_belief = sum(
n.mean_belief * self.degree(n) / influence for n in neighbours
)
mean_belief = mean_belief * self.vulnerability + self.mean_belief * (
1 - self.vulnerability
)
self.mean_belief = self.mean_belief ** (
1 - self.terrorist_additional_influence
)
# Check if there are any leaders in the group
leaders = list(filter(lambda x: x.state.id == self.leader.id, neighbours))
@@ -82,21 +109,29 @@ class TerroristSpreadModel(FSM, Geo):
return self.leader
def ego_search(self, steps=1, center=False, node=None, **kwargs):
'''Get a list of nodes in the ego network of *node* of radius *steps*'''
"""Get a list of nodes in the ego network of *node* of radius *steps*"""
node = as_node(node if node is not None else self)
G = self.subgraph(**kwargs)
return nx.ego_graph(G, node, center=center, radius=steps).nodes()
def degree(self, node, force=False):
node = as_node(node)
if force or (not hasattr(self.model, '_degree')) or getattr(self.model, '_last_step', 0) < self.now:
if (
force
or (not hasattr(self.model, "_degree"))
or getattr(self.model, "_last_step", 0) < self.now
):
self.model._degree = nx.degree_centrality(self.G)
self.model._last_step = self.now
return self.model._degree[node]
def betweenness(self, node, force=False):
node = as_node(node)
if force or (not hasattr(self.model, '_betweenness')) or getattr(self.model, '_last_step', 0) < self.now:
if (
force
or (not hasattr(self.model, "_betweenness"))
or getattr(self.model, "_last_step", 0) < self.now
):
self.model._betweenness = nx.betweenness_centrality(self.G)
self.model._last_step = self.now
return self.model._betweenness[node]
@@ -114,17 +149,20 @@ class TrainingAreaModel(FSM, Geo):
def __init__(self, model=None, unique_id=0, state=()):
super().__init__(model=model, unique_id=unique_id, state=state)
self.training_influence = model.environment_params['training_influence']
if 'min_vulnerability' in model.environment_params:
self.min_vulnerability = model.environment_params['min_vulnerability']
else: self.min_vulnerability = 0
self.training_influence = model.environment_params["training_influence"]
if "min_vulnerability" in model.environment_params:
self.min_vulnerability = model.environment_params["min_vulnerability"]
else:
self.min_vulnerability = 0
@default_state
@state
def terrorist(self):
for neighbour in self.get_neighboring_agents(agent_class=TerroristSpreadModel):
for neighbour in self.get_neighbors(agent_class=TerroristSpreadModel):
if neighbour.vulnerability > self.min_vulnerability:
neighbour.vulnerability = neighbour.vulnerability ** ( 1 - self.training_influence )
neighbour.vulnerability = neighbour.vulnerability ** (
1 - self.training_influence
)
class HavenModel(FSM, Geo):
@@ -141,14 +179,15 @@ class HavenModel(FSM, Geo):
def __init__(self, model=None, unique_id=0, state=()):
super().__init__(model=model, unique_id=unique_id, state=state)
self.haven_influence = model.environment_params['haven_influence']
if 'min_vulnerability' in model.environment_params:
self.min_vulnerability = model.environment_params['min_vulnerability']
else: self.min_vulnerability = 0
self.max_vulnerability = model.environment_params['max_vulnerability']
self.haven_influence = model.environment_params["haven_influence"]
if "min_vulnerability" in model.environment_params:
self.min_vulnerability = model.environment_params["min_vulnerability"]
else:
self.min_vulnerability = 0
self.max_vulnerability = model.environment_params["max_vulnerability"]
def get_occupants(self, **kwargs):
return self.get_neighboring_agents(agent_class=TerroristSpreadModel, **kwargs)
return self.get_neighbors(agent_class=TerroristSpreadModel, **kwargs)
@state
def civilian(self):
@@ -158,14 +197,18 @@ class HavenModel(FSM, Geo):
for neighbour in self.get_occupants():
if neighbour.vulnerability > self.min_vulnerability:
neighbour.vulnerability = neighbour.vulnerability * ( 1 - self.haven_influence )
neighbour.vulnerability = neighbour.vulnerability * (
1 - self.haven_influence
)
return self.civilian
@state
def terrorist(self):
for neighbour in self.get_occupants():
if neighbour.vulnerability < self.max_vulnerability:
neighbour.vulnerability = neighbour.vulnerability ** ( 1 - self.haven_influence )
neighbour.vulnerability = neighbour.vulnerability ** (
1 - self.haven_influence
)
return self.terrorist
@@ -184,10 +227,10 @@ class TerroristNetworkModel(TerroristSpreadModel):
def __init__(self, model=None, unique_id=0, state=()):
super().__init__(model=model, unique_id=unique_id, state=state)
self.vision_range = model.environment_params['vision_range']
self.sphere_influence = model.environment_params['sphere_influence']
self.weight_social_distance = model.environment_params['weight_social_distance']
self.weight_link_distance = model.environment_params['weight_link_distance']
self.vision_range = model.environment_params["vision_range"]
self.sphere_influence = model.environment_params["sphere_influence"]
self.weight_social_distance = model.environment_params["weight_social_distance"]
self.weight_link_distance = model.environment_params["weight_link_distance"]
@state
def terrorist(self):
@@ -200,28 +243,49 @@ class TerroristNetworkModel(TerroristSpreadModel):
return super().leader()
def update_relationships(self):
if self.count_neighboring_agents(state_id=self.civilian.id) == 0:
close_ups = set(self.geo_search(radius=self.vision_range, agent_class=TerroristNetworkModel))
step_neighbours = set(self.ego_search(self.sphere_influence, agent_class=TerroristNetworkModel, center=False))
neighbours = set(agent.id for agent in self.get_neighboring_agents(agent_class=TerroristNetworkModel))
if self.count_neighbors(state_id=self.civilian.id) == 0:
close_ups = set(
self.geo_search(
radius=self.vision_range, agent_class=TerroristNetworkModel
)
)
step_neighbours = set(
self.ego_search(
self.sphere_influence,
agent_class=TerroristNetworkModel,
center=False,
)
)
neighbours = set(
agent.id
for agent in self.get_neighbors(
agent_class=TerroristNetworkModel
)
)
search = (close_ups | step_neighbours) - neighbours
for agent in self.get_agents(search):
social_distance = 1 / self.shortest_path_length(agent.id)
spatial_proximity = ( 1 - self.get_distance(agent.id) )
prob_new_interaction = self.weight_social_distance * social_distance + self.weight_link_distance * spatial_proximity
if agent['id'] == agent.civilian.id and self.random.random() < prob_new_interaction:
spatial_proximity = 1 - self.get_distance(agent.id)
prob_new_interaction = (
self.weight_social_distance * social_distance
+ self.weight_link_distance * spatial_proximity
)
if (
agent["id"] == agent.civilian.id
and self.random.random() < prob_new_interaction
):
self.add_edge(agent)
break
def get_distance(self, target):
source_x, source_y = nx.get_node_attributes(self.G, 'pos')[self.id]
target_x, target_y = nx.get_node_attributes(self.G, 'pos')[target]
dx = abs( source_x - target_x )
dy = abs( source_y - target_y )
return ( dx ** 2 + dy ** 2 ) ** ( 1 / 2 )
source_x, source_y = nx.get_node_attributes(self.G, "pos")[self.id]
target_x, target_y = nx.get_node_attributes(self.G, "pos")[target]
dx = abs(source_x - target_x)
dy = abs(source_y - target_y)
return (dx**2 + dy**2) ** (1 / 2)
def shortest_path_length(self, target):
try:
return nx.shortest_path_length(self.G, self.id, target)
except nx.NetworkXNoPath:
return float('inf')
return float("inf")

View File

@@ -1 +1 @@
0.20.7
0.30.0rc2

View File

@@ -5,6 +5,7 @@ import sys
import os
import logging
import traceback
from contextlib import contextmanager
from .version import __version__
@@ -16,7 +17,7 @@ except NameError:
from .agents import *
from . import agents
from .simulation import *
from .environment import Environment
from .environment import Environment, EventedEnvironment
from . import serialization
from .utils import logger
from .time import *
@@ -30,8 +31,12 @@ def main(
*,
do_run=False,
debug=False,
pdb=False,
**kwargs,
):
if isinstance(cfg, Simulation):
sim = cfg
import argparse
from . import simulation
@@ -42,7 +47,7 @@ def main(
"file",
type=str,
nargs="?",
default=cfg,
default=cfg if sim is None else '',
help="Configuration file for the simulation (e.g., YAML or JSON)",
)
parser.add_argument(
@@ -148,30 +153,41 @@ def main(
if output is None:
output = args.output
logger.info("Loading config file: {}".format(args.file))
debug = debug or args.debug
if args.pdb or debug:
args.synchronous = True
os.environ["SOIL_POSTMORTEM"] = "true"
res = []
try:
exp_params = {}
if not os.path.exists(args.file):
logger.error("Please, input a valid file")
return
if sim:
logger.info("Loading simulation instance")
sim.dry_run = args.dry_run
sim.exporters = exporters
sim.parallel = parallel
sim.outdir = output
sims = [sim, ]
else:
logger.info("Loading config file: {}".format(args.file))
if not os.path.exists(args.file):
logger.error("Please, input a valid file")
return
sims = list(simulation.iter_from_config(
args.file,
dry_run=args.dry_run,
exporters=exporters,
parallel=parallel,
outdir=output,
exporter_params=exp_params,
**kwargs,
))
for sim in sims:
for sim in simulation.iter_from_config(
args.file,
dry_run=args.dry_run,
exporters=exporters,
parallel=parallel,
outdir=output,
exporter_params=exp_params,
**kwargs,
):
if args.set:
for s in args.set:
k, v = s.split("=", 1)[:2]
@@ -214,8 +230,17 @@ def main(
return res
def easy(cfg, debug=False, **kwargs):
return main(cfg, **kwargs)[0]
@contextmanager
def easy(cfg, pdb=False, debug=False, **kwargs):
try:
yield main(cfg, debug=debug, pdb=pdb, **kwargs)[0]
except Exception as e:
if os.environ.get("SOIL_POSTMORTEM"):
from .debugging import post_mortem
print(traceback.format_exc())
post_mortem()
raise
if __name__ == "__main__":

View File

@@ -20,7 +20,7 @@ class BassModel(FSM):
self.sentimentCorrelation = 1
return self.aware
else:
aware_neighbors = self.get_neighboring_agents(state_id=self.aware.id)
aware_neighbors = self.get_neighbors(state_id=self.aware.id)
num_neighbors_aware = len(aware_neighbors)
if self.prob((self["imitation_prob"] * num_neighbors_aware)):
self.sentimentCorrelation = 1

View File

@@ -24,14 +24,14 @@ class BigMarketModel(FSM):
self.type = ""
if self.id < len(self.enterprises): # Enterprises
self.set_state(self.enterprise.id)
self._set_state(self.enterprise.id)
self.type = "Enterprise"
self.tweet_probability = environment.environment_params[
"tweet_probability_enterprises"
][self.id]
else: # normal users
self.type = "User"
self.set_state(self.user.id)
self._set_state(self.user.id)
self.tweet_probability = environment.environment_params[
"tweet_probability_users"
]
@@ -49,7 +49,7 @@ class BigMarketModel(FSM):
def enterprise(self):
if self.random.random() < self.tweet_probability: # Tweets
aware_neighbors = self.get_neighboring_agents(
aware_neighbors = self.get_neighbors(
state_id=self.number_of_enterprises
) # Nodes neighbour users
for x in aware_neighbors:
@@ -96,7 +96,7 @@ class BigMarketModel(FSM):
] = self.sentiment_about[i]
def userTweets(self, sentiment, enterprise):
aware_neighbors = self.get_neighboring_agents(
aware_neighbors = self.get_neighbors(
state_id=self.number_of_enterprises
) # Nodes neighbours users
for x in aware_neighbors:

View File

@@ -14,7 +14,7 @@ class CounterModel(NetworkAgent):
def step(self):
# Outside effects
total = len(list(self.model.schedule._agents))
neighbors = len(list(self.get_neighboring_agents()))
neighbors = len(list(self.get_neighbors()))
self["times"] = self.get("times", 0) + 1
self["neighbors"] = neighbors
self["total"] = total
@@ -33,7 +33,7 @@ class AggregatedCounter(NetworkAgent):
def step(self):
# Outside effects
self["times"] += 1
neighbors = len(list(self.get_neighboring_agents()))
neighbors = len(list(self.get_neighbors()))
self["neighbors"] += neighbors
total = len(list(self.model.schedule.agents))
self["total"] += total

View File

@@ -36,7 +36,7 @@ class IndependentCascadeModel(BaseAgent):
# Imitation effects
if self.state["id"] == 0:
aware_neighbors = self.get_neighboring_agents(state_id=1)
aware_neighbors = self.get_neighbors(state_id=1)
for x in aware_neighbors:
if x.state["time_awareness"] == (self.env.now - 1):
aware_neighbors_1_time_step.append(x)

View File

@@ -71,7 +71,7 @@ class SpreadModelM2(BaseAgent):
def neutral_behaviour(self):
# Infected
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
if len(infected_neighbors) > 0:
if self.prob(self.prob_neutral_making_denier):
self.state["id"] = 3 # Vaccinated making denier
@@ -79,7 +79,7 @@ class SpreadModelM2(BaseAgent):
def infected_behaviour(self):
# Neutral
neutral_neighbors = self.get_neighboring_agents(state_id=0)
neutral_neighbors = self.get_neighbors(state_id=0)
for neighbor in neutral_neighbors:
if self.prob(self.prob_infect):
neighbor.state["id"] = 1 # Infected
@@ -87,13 +87,13 @@ class SpreadModelM2(BaseAgent):
def cured_behaviour(self):
# Vaccinate
neutral_neighbors = self.get_neighboring_agents(state_id=0)
neutral_neighbors = self.get_neighbors(state_id=0)
for neighbor in neutral_neighbors:
if self.prob(self.prob_cured_vaccinate_neutral):
neighbor.state["id"] = 3 # Vaccinated
# Cure
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
for neighbor in infected_neighbors:
if self.prob(self.prob_cured_healing_infected):
neighbor.state["id"] = 2 # Cured
@@ -101,19 +101,19 @@ class SpreadModelM2(BaseAgent):
def vaccinated_behaviour(self):
# Cure
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
for neighbor in infected_neighbors:
if self.prob(self.prob_cured_healing_infected):
neighbor.state["id"] = 2 # Cured
# Vaccinate
neutral_neighbors = self.get_neighboring_agents(state_id=0)
neutral_neighbors = self.get_neighbors(state_id=0)
for neighbor in neutral_neighbors:
if self.prob(self.prob_cured_vaccinate_neutral):
neighbor.state["id"] = 3 # Vaccinated
# Generate anti-rumor
infected_neighbors_2 = self.get_neighboring_agents(state_id=1)
infected_neighbors_2 = self.get_neighbors(state_id=1)
for neighbor in infected_neighbors_2:
if self.prob(self.prob_generate_anti_rumor):
neighbor.state["id"] = 2 # Cured
@@ -191,7 +191,7 @@ class ControlModelM2(BaseAgent):
self.state["visible"] = False
# Infected
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
if len(infected_neighbors) > 0:
if self.random(self.prob_neutral_making_denier):
self.state["id"] = 3 # Vaccinated making denier
@@ -199,7 +199,7 @@ class ControlModelM2(BaseAgent):
def infected_behaviour(self):
# Neutral
neutral_neighbors = self.get_neighboring_agents(state_id=0)
neutral_neighbors = self.get_neighbors(state_id=0)
for neighbor in neutral_neighbors:
if self.prob(self.prob_infect):
neighbor.state["id"] = 1 # Infected
@@ -209,13 +209,13 @@ class ControlModelM2(BaseAgent):
self.state["visible"] = True
# Vaccinate
neutral_neighbors = self.get_neighboring_agents(state_id=0)
neutral_neighbors = self.get_neighbors(state_id=0)
for neighbor in neutral_neighbors:
if self.prob(self.prob_cured_vaccinate_neutral):
neighbor.state["id"] = 3 # Vaccinated
# Cure
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
for neighbor in infected_neighbors:
if self.prob(self.prob_cured_healing_infected):
neighbor.state["id"] = 2 # Cured
@@ -224,47 +224,47 @@ class ControlModelM2(BaseAgent):
self.state["visible"] = True
# Cure
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
for neighbor in infected_neighbors:
if self.prob(self.prob_cured_healing_infected):
neighbor.state["id"] = 2 # Cured
# Vaccinate
neutral_neighbors = self.get_neighboring_agents(state_id=0)
neutral_neighbors = self.get_neighbors(state_id=0)
for neighbor in neutral_neighbors:
if self.prob(self.prob_cured_vaccinate_neutral):
neighbor.state["id"] = 3 # Vaccinated
# Generate anti-rumor
infected_neighbors_2 = self.get_neighboring_agents(state_id=1)
infected_neighbors_2 = self.get_neighbors(state_id=1)
for neighbor in infected_neighbors_2:
if self.prob(self.prob_generate_anti_rumor):
neighbor.state["id"] = 2 # Cured
def beacon_off_behaviour(self):
self.state["visible"] = False
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
if len(infected_neighbors) > 0:
self.state["id"] == 5 # Beacon on
def beacon_on_behaviour(self):
self.state["visible"] = False
# Cure (M2 feature added)
infected_neighbors = self.get_neighboring_agents(state_id=1)
infected_neighbors = self.get_neighbors(state_id=1)
for neighbor in infected_neighbors:
if self.prob(self.prob_generate_anti_rumor):
neighbor.state["id"] = 2 # Cured
neutral_neighbors_infected = neighbor.get_neighboring_agents(state_id=0)
neutral_neighbors_infected = neighbor.get_neighbors(state_id=0)
for neighbor in neutral_neighbors_infected:
if self.prob(self.prob_generate_anti_rumor):
neighbor.state["id"] = 3 # Vaccinated
infected_neighbors_infected = neighbor.get_neighboring_agents(state_id=1)
infected_neighbors_infected = neighbor.get_neighbors(state_id=1)
for neighbor in infected_neighbors_infected:
if self.prob(self.prob_generate_anti_rumor):
neighbor.state["id"] = 2 # Cured
# Vaccinate
neutral_neighbors = self.get_neighboring_agents(state_id=0)
neutral_neighbors = self.get_neighbors(state_id=0)
for neighbor in neutral_neighbors:
if self.prob(self.prob_cured_vaccinate_neutral):
neighbor.state["id"] = 3 # Vaccinated

View File

@@ -69,10 +69,10 @@ class SISaModel(FSM):
return self.content
# Infected
discontent_neighbors = self.count_neighboring_agents(state_id=self.discontent)
discontent_neighbors = self.count_neighbors(state_id=self.discontent)
if self.prob(scontent_neighbors * self.neutral_discontent_infected_prob):
return self.discontent
content_neighbors = self.count_neighboring_agents(state_id=self.content.id)
content_neighbors = self.count_neighbors(state_id=self.content.id)
if self.prob(s * self.neutral_content_infected_prob):
return self.content
return self.neutral
@@ -84,7 +84,7 @@ class SISaModel(FSM):
return self.neutral
# Superinfected
content_neighbors = self.count_neighboring_agents(state_id=self.content.id)
content_neighbors = self.count_neighbors(state_id=self.content.id)
if self.prob(s * self.discontent_content):
return self.content
return self.discontent
@@ -96,9 +96,7 @@ class SISaModel(FSM):
return self.neutral
# Superinfected
discontent_neighbors = self.count_neighboring_agents(
state_id=self.discontent.id
)
discontent_neighbors = self.count_neighbors(state_id=self.discontent.id)
if self.prob(scontent_neighbors * self.content_discontent):
self.discontent
return self.content

View File

@@ -41,25 +41,25 @@ class SentimentCorrelationModel(BaseAgent):
sad_neighbors_1_time_step = []
disgusted_neighbors_1_time_step = []
angry_neighbors = self.get_neighboring_agents(state_id=1)
angry_neighbors = self.get_neighbors(state_id=1)
for x in angry_neighbors:
if x.state["time_awareness"][0] > (self.env.now - 500):
angry_neighbors_1_time_step.append(x)
num_neighbors_angry = len(angry_neighbors_1_time_step)
joyful_neighbors = self.get_neighboring_agents(state_id=2)
joyful_neighbors = self.get_neighbors(state_id=2)
for x in joyful_neighbors:
if x.state["time_awareness"][1] > (self.env.now - 500):
joyful_neighbors_1_time_step.append(x)
num_neighbors_joyful = len(joyful_neighbors_1_time_step)
sad_neighbors = self.get_neighboring_agents(state_id=3)
sad_neighbors = self.get_neighbors(state_id=3)
for x in sad_neighbors:
if x.state["time_awareness"][2] > (self.env.now - 500):
sad_neighbors_1_time_step.append(x)
num_neighbors_sad = len(sad_neighbors_1_time_step)
disgusted_neighbors = self.get_neighboring_agents(state_id=4)
disgusted_neighbors = self.get_neighbors(state_id=4)
for x in disgusted_neighbors:
if x.state["time_awareness"][3] > (self.env.now - 500):
disgusted_neighbors_1_time_step.append(x)

View File

@@ -29,10 +29,6 @@ def as_node(agent):
IGNORED_FIELDS = ("model", "logger")
class DeadAgent(Exception):
pass
class MetaAgent(ABCMeta):
def __new__(mcls, name, bases, namespace):
defaults = {}
@@ -44,10 +40,36 @@ class MetaAgent(ABCMeta):
new_nmspc = {
"_defaults": defaults,
"_last_return": None,
"_last_except": None,
}
for attr, func in namespace.items():
if (
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
new_nmspc[attr] = func
elif (
isinstance(func, types.FunctionType)
or isinstance(func, property)
or isinstance(func, classmethod)
@@ -176,11 +198,15 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def die(self):
self.info(f"agent dying")
self.alive = False
try:
self.model.schedule.remove(self)
except KeyError:
pass
return time.NEVER
def step(self):
if not self.alive:
raise DeadAgent(self.unique_id)
raise time.DeadAgent(self.unique_id)
return super().step() or time.Delta(self.interval)
def log(self, message, *args, level=logging.INFO, **kwargs):
@@ -229,215 +255,6 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
return f"{self.__class__.__name__}({self.unique_id})"
class NetworkAgent(BaseAgent):
def __init__(self, *args, topology, node_id, **kwargs):
super().__init__(*args, **kwargs)
assert topology is not None
assert node_id is not None
self.G = topology
assert self.G
self.node_id = node_id
def count_neighboring_agents(self, state_id=None, **kwargs):
return len(self.get_neighboring_agents(state_id=state_id, **kwargs))
def get_neighboring_agents(self, **kwargs):
return list(self.iter_agents(limit_neighbors=True, **kwargs))
def add_edge(self, other):
self.topology.add_edge(self.node_id, other.node_id)
@property
def node(self):
return self.topology.nodes[self.node_id]
def iter_agents(self, unique_id=None, *, limit_neighbors=False, **kwargs):
unique_ids = None
if isinstance(unique_id, list):
unique_ids = set(unique_id)
elif unique_id is not None:
unique_ids = set(
[
unique_id,
]
)
if limit_neighbors:
neighbor_ids = set()
for node_id in self.G.neighbors(self.node_id):
if self.G.nodes[node_id].get("agent") is not None:
neighbor_ids.add(node_id)
if unique_ids:
unique_ids = unique_ids & neighbor_ids
else:
unique_ids = neighbor_ids
if not unique_ids:
return
unique_ids = list(unique_ids)
yield from super().iter_agents(unique_id=unique_ids, **kwargs)
def subgraph(self, center=True, **kwargs):
include = [self] if center else []
G = self.G.subgraph(
n.node_id for n in list(self.get_agents(**kwargs) + include)
)
return G
def remove_node(self):
self.G.remove_node(self.node_id)
def add_edge(self, other, edge_attr_dict=None, *edge_attrs):
if self.node_id not in self.G.nodes(data=False):
raise ValueError(
"{} not in list of existing agents in the network".format(
self.unique_id
)
)
if other.node_id not in self.G.nodes(data=False):
raise ValueError(
"{} not in list of existing agents in the network".format(other)
)
self.G.add_edge(
self.node_id, other.node_id, edge_attr_dict=edge_attr_dict, *edge_attrs
)
def die(self, remove=True):
if remove:
self.remove_node()
return super().die()
def state(name=None):
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:
n = next(self._coroutine)
if n:
return None, n
return
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
func.id = name or func.__name__
func.is_default = False
return func
if callable(name):
return decorator(name)
else:
return partial(decorator, name=name)
def default_state(func):
func.is_default = True
return func
class MetaFSM(MetaAgent):
def __new__(mcls, name, bases, namespace):
states = {}
# Re-use states from inherited classes
default_state = None
for i in bases:
if isinstance(i, MetaFSM):
for state_id, state in i._states.items():
if state.is_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:
default_state = func
states[func.id] = func
namespace.update(
{
"_default_state": default_state,
"_states": states,
}
)
return super(MetaFSM, mcls).__new__(
mcls=mcls, name=name, bases=bases, namespace=namespace
)
class FSM(BaseAgent, metaclass=MetaFSM):
def __init__(self, *args, **kwargs):
super(FSM, self).__init__(*args, **kwargs)
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.set_state(self.state_id)
def step(self):
self.debug(f"Agent {self.unique_id} @ state {self.state_id}")
default_interval = super().step()
next_state = self._states[self.state_id](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 default_interval
def set_state(self, state, when=None):
if hasattr(state, "id"):
state = state.id
if state not in self._states:
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
def die(self):
return self.dead, super().die()
@state
def dead(self):
return self.die()
def prob(prob, random):
"""
A true/False uniform distribution with a given probability.
@@ -503,7 +320,7 @@ def calculate_distribution(network_agents=None, agent_class=None):
return network_agents
def serialize_type(agent_class, known_modules=[], **kwargs):
def _serialize_type(agent_class, known_modules=[], **kwargs):
if isinstance(agent_class, str):
return agent_class
known_modules += ["soil.agents"]
@@ -512,20 +329,7 @@ def serialize_type(agent_class, known_modules=[], **kwargs):
] # Get the name of the class
def serialize_definition(network_agents, known_modules=[]):
"""
When serializing an agent distribution, remove the thresholds, in order
to avoid cluttering the YAML definition file.
"""
d = deepcopy(list(network_agents))
for v in d:
if "threshold" in v:
del v["threshold"]
v["agent_class"] = serialize_type(v["agent_class"], known_modules=known_modules)
return d
def deserialize_type(agent_class, known_modules=[]):
def _deserialize_type(agent_class, known_modules=[]):
if not isinstance(agent_class, str):
return agent_class
known = known_modules + ["soil.agents", "soil.agents.custom"]
@@ -533,108 +337,6 @@ def deserialize_type(agent_class, known_modules=[]):
return agent_class
def deserialize_definition(ind, **kwargs):
d = deepcopy(ind)
for v in d:
v["agent_class"] = deserialize_type(v["agent_class"], **kwargs)
return d
def _validate_states(states, topology):
"""Validate states to avoid ignoring states during initialization"""
states = states or []
if isinstance(states, dict):
for x in states:
assert x in topology.nodes
else:
assert len(states) <= len(topology)
return states
def _convert_agent_classs(ind, to_string=False, **kwargs):
"""Convenience method to allow specifying agents by class or class name."""
if to_string:
return serialize_definition(ind, **kwargs)
return deserialize_definition(ind, **kwargs)
# def _agent_from_definition(definition, random, value=-1, unique_id=None):
# """Used in the initialization of agents given an agent distribution."""
# if value < 0:
# value = random.random()
# for d in sorted(definition, key=lambda x: x.get('threshold')):
# threshold = d.get('threshold', (-1, -1))
# # Check if the definition matches by id (first) or by threshold
# if (unique_id is not None and unique_id in d.get('ids', [])) or \
# (value >= threshold[0] and value < threshold[1]):
# state = {}
# if 'state' in d:
# state = deepcopy(d['state'])
# return d['agent_class'], state
# raise Exception('Definition for value {} not found in: {}'.format(value, definition))
# def _definition_to_dict(definition, random, size=None, default_state=None):
# state = default_state or {}
# agents = {}
# remaining = {}
# if size:
# for ix in range(size):
# remaining[ix] = copy(state)
# else:
# remaining = defaultdict(lambda x: copy(state))
# distro = sorted([item for item in definition if 'weight' in item])
# id = 0
# def init_agent(item, id=ix):
# while id in agents:
# id += 1
# agent = remaining[id]
# agent['state'].update(copy(item.get('state', {})))
# agents[agent.unique_id] = agent
# del remaining[id]
# return agent
# for item in definition:
# if 'ids' in item:
# ids = item['ids']
# del item['ids']
# for id in ids:
# agent = init_agent(item, id)
# for item in definition:
# if 'number' in item:
# times = item['number']
# del item['number']
# for times in range(times):
# if size:
# ix = random.choice(remaining.keys())
# agent = init_agent(item, id)
# else:
# agent = init_agent(item)
# if not size:
# return agents
# if len(remaining) < 0:
# raise Exception('Invalid definition. Too many agents to add')
# total_weight = float(sum(s['weight'] for s in distro))
# unit = size / total_weight
# for item in distro:
# times = unit * item['weight']
# del item['weight']
# for times in range(times):
# ix = random.choice(remaining.keys())
# agent = init_agent(item, id)
# return agents
class AgentView(Mapping, Set):
"""A lazy-loaded list of agents."""
@@ -718,7 +420,7 @@ def filter_agents(
state_id = tuple([state_id])
if agent_class is not None:
agent_class = deserialize_type(agent_class)
agent_class = _deserialize_type(agent_class)
try:
agent_class = tuple(agent_class)
except TypeError:
@@ -758,14 +460,6 @@ def from_config(
default = cfg or config.AgentConfig()
if not isinstance(cfg, config.AgentConfig):
cfg = config.AgentConfig(**cfg)
return _agents_from_config(cfg, topology=topology, random=random)
def _agents_from_config(
cfg: config.AgentConfig, topology: nx.Graph, random
) -> List[Dict[str, Any]]:
if cfg and not isinstance(cfg, config.AgentConfig):
cfg = config.AgentConfig(**cfg)
agents = []
@@ -933,6 +627,9 @@ def _from_distro(
return agents
from .network_agents import *
from .fsm import *
from .evented import *
from .BassModel import *
from .BigMarketModel import *
from .IndependentCascadeModel import *

57
soil/agents/evented.py Normal file
View File

@@ -0,0 +1,57 @@
from . import BaseAgent
from ..events import Message, Tell, Ask, Reply, TimedOut
from ..time import Cond
from functools import partial
from collections import deque
class Evented(BaseAgent):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._inbox = deque()
self._received = 0
self._processed = 0
def on_receive(self, *args, **kwargs):
pass
def received(self, expiration=None, timeout=None):
current = self._received
if expiration is None:
expiration = float('inf') if timeout is None else self.now + timeout
if expiration < self.now:
raise ValueError("Invalid expiration time")
def ready(agent):
return agent._received > current or agent.now >= expiration
def value(agent):
if agent.now > expiration:
raise TimedOut("No message received")
c = Cond(func=ready, return_func=value)
c._checked = True
return c
def tell(self, msg, sender):
self._received += 1
self._inbox.append(Tell(payload=msg, sender=sender))
def ask(self, msg, timeout=None):
self._received += 1
ask = Ask(payload=msg)
self._inbox.append(ask)
expiration = float('inf') if timeout is None else self.now + timeout
return ask.replied(expiration=expiration)
def check_messages(self):
while self._inbox:
msg = self._inbox.popleft()
self._processed += 1
if msg.expired(self.now):
continue
reply = self.on_receive(msg.payload, sender=msg.sender)
if isinstance(msg, Ask):
msg.reply = reply

142
soil/agents/fsm.py Normal file
View File

@@ -0,0 +1,142 @@
from . import MetaAgent, BaseAgent
from functools import partial, wraps
import inspect
def state(name=None):
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 = False
return func
if callable(name):
return decorator(name)
else:
return partial(decorator, name=name)
def default_state(func):
func.is_default = True
return func
class MetaFSM(MetaAgent):
def __new__(mcls, name, bases, namespace):
states = {}
# Re-use states from inherited classes
default_state = None
for i in bases:
if isinstance(i, MetaFSM):
for state_id, state in i._states.items():
if state.is_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:
default_state = func
states[func.id] = func
namespace.update(
{
"_default_state": default_state,
"_states": states,
}
)
return super(MetaFSM, mcls).__new__(
mcls=mcls, name=name, bases=bases, namespace=namespace
)
class FSM(BaseAgent, metaclass=MetaFSM):
def __init__(self, **kwargs):
super(FSM, self).__init__(**kwargs)
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._set_state(self.state_id)
def step(self):
self.debug(f"Agent {self.unique_id} @ state {self.state_id}")
default_interval = super().step()
next_state = self._states[self.state_id](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 default_interval
def _set_state(self, state, when=None):
if hasattr(state, "id"):
state = state.id
if state not in self._states:
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
def die(self):
return self.dead, super().die()
@state
def dead(self):
return self.die()

View File

@@ -0,0 +1,82 @@
from . import BaseAgent
class NetworkAgent(BaseAgent):
def __init__(self, *args, topology, node_id, **kwargs):
super().__init__(*args, **kwargs)
assert topology is not None
assert node_id is not None
self.G = topology
assert self.G
self.node_id = node_id
def count_neighbors(self, state_id=None, **kwargs):
return len(self.get_neighbors(state_id=state_id, **kwargs))
def get_neighbors(self, **kwargs):
return list(self.iter_agents(limit_neighbors=True, **kwargs))
@property
def node(self):
return self.G.nodes[self.node_id]
def iter_agents(self, unique_id=None, *, limit_neighbors=False, **kwargs):
unique_ids = None
if isinstance(unique_id, list):
unique_ids = set(unique_id)
elif unique_id is not None:
unique_ids = set(
[
unique_id,
]
)
if limit_neighbors:
neighbor_ids = set()
for node_id in self.G.neighbors(self.node_id):
if self.G.nodes[node_id].get("agent") is not None:
neighbor_ids.add(node_id)
if unique_ids:
unique_ids = unique_ids & neighbor_ids
else:
unique_ids = neighbor_ids
if not unique_ids:
return
unique_ids = list(unique_ids)
yield from super().iter_agents(unique_id=unique_ids, **kwargs)
def subgraph(self, center=True, **kwargs):
include = [self] if center else []
G = self.G.subgraph(
n.node_id for n in list(self.get_agents(**kwargs) + include)
)
return G
def remove_node(self):
print(f"Removing node for {self.unique_id}: {self.node_id}")
self.G.remove_node(self.node_id)
self.node_id = None
def add_edge(self, other, edge_attr_dict=None, *edge_attrs):
if self.node_id not in self.G.nodes(data=False):
raise ValueError(
"{} not in list of existing agents in the network".format(
self.unique_id
)
)
if other.node_id not in self.G.nodes(data=False):
raise ValueError(
"{} not in list of existing agents in the network".format(other)
)
self.G.add_edge(
self.node_id, other.node_id, edge_attr_dict=edge_attr_dict, *edge_attrs
)
def die(self, remove=True):
if not self.alive:
return None
if remove:
self.remove_node()
return super().die()

View File

@@ -30,9 +30,9 @@ def wrapcmd(func):
class Debug(pdb.Pdb):
def __init__(self, *args, skip_soil=False, **kwargs):
skip = kwargs.get("skip", [])
skip.append("soil")
if skip_soil:
skip.append("soil")
skip.append("contextlib")
skip.append("soil.*")
skip.append("mesa.*")
super(Debug, self).__init__(*args, skip=skip, **kwargs)
@@ -181,7 +181,7 @@ def set_trace(frame=None, **kwargs):
debugger.set_trace(frame)
def post_mortem(traceback=None):
def post_mortem(traceback=None, **kwargs):
global debugger
if debugger is None:
debugger = Debug(**kwargs)

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import os
import sqlite3
import math
import random
import logging
import inspect
@@ -19,7 +18,7 @@ import networkx as nx
from mesa import Model
from mesa.datacollection import DataCollector
from . import agents as agentmod, config, serialization, utils, time, network
from . import agents as agentmod, config, serialization, utils, time, network, events
class BaseEnvironment(Model):
@@ -142,12 +141,12 @@ class BaseEnvironment(Model):
"The environment has not been scheduled, so it has no sense of time"
)
def add_agent(self, agent_class, unique_id=None, **kwargs):
a = None
def add_agent(self, unique_id=None, **kwargs):
if unique_id is None:
unique_id = self.next_id()
a = agent_class(model=self, unique_id=unique_id, **args)
kwargs["unique_id"] = unique_id
a = self._agent_from_dict(kwargs)
self.schedule.add(a)
return a
@@ -169,7 +168,9 @@ class BaseEnvironment(Model):
Advance one step in the simulation, and update the data collection and scheduler appropriately
"""
super().step()
self.logger.info(f"--- Step {self.now:^5} ---")
self.logger.info(
f"--- Step: {self.schedule.steps:^5} - Time: {self.now:^5} ---"
)
self.schedule.step()
self.datacollector.collect(self)
@@ -236,6 +237,7 @@ class NetworkEnvironment(BaseEnvironment):
node_id = agent.get("node_id", None)
if node_id is None:
node_id = network.find_unassigned(self.G, random=self.random)
self.G.nodes[node_id]["agent"] = None
agent["node_id"] = node_id
agent["unique_id"] = unique_id
agent["topology"] = self.G
@@ -269,18 +271,31 @@ class NetworkEnvironment(BaseEnvironment):
node_id = network.find_unassigned(
G=self.G, shuffle=True, random=self.random
)
if node_id is None:
node_id = f"node_for_{unique_id}"
if node_id in G.nodes:
self.G.nodes[node_id]["agent"] = None # Reserve
else:
if node_id not in self.G.nodes:
self.G.add_node(node_id)
assert "agent" not in self.G.nodes[node_id]
self.G.nodes[node_id]["agent"] = None # Reserve
a = self.add_agent(
unique_id=unique_id, agent_class=agent_class, node_id=node_id, **kwargs
unique_id=unique_id,
agent_class=agent_class,
topology=self.G,
node_id=node_id,
**kwargs,
)
a["visible"] = True
return a
def add_agent(self, *args, **kwargs):
a = super().add_agent(*args, **kwargs)
if "node_id" in a:
assert self.G.nodes[a.node_id]["agent"] == a
return a
def agent_for_node_id(self, node_id):
return self.G.nodes[node_id].get("agent")
@@ -296,3 +311,14 @@ class NetworkEnvironment(BaseEnvironment):
Environment = NetworkEnvironment
class EventedEnvironment(Environment):
def broadcast(self, msg, sender, expiration=None, ttl=None, **kwargs):
for agent in self.agents(**kwargs):
self.logger.info(f'Telling {repr(agent)}: {msg} ttl={ttl}')
try:
agent._inbox.append(events.Tell(payload=msg, sender=sender, expiration=expiration if ttl is None else self.now+ttl))
except AttributeError:
self.info(f'Agent {agent.unique_id} cannot receive events')

43
soil/events.py Normal file
View File

@@ -0,0 +1,43 @@
from .time import Cond
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
class Event:
pass
@dataclass
class Message:
payload: Any
sender: Any = None
expiration: float = None
id: int = field(default_factory=uuid4)
def expired(self, when):
return self.expiration is not None and self.expiration < when
class Reply(Message):
source: Message
class Ask(Message):
reply: Message = None
def replied(self, expiration=None):
def ready(agent):
return self.reply is not None or agent.now > expiration
def value(agent):
if agent.now > expiration:
raise TimedOut(f'No answer received for {self}')
return self.reply
return Cond(func=ready, return_func=value)
class Tell(Message):
pass
class TimedOut(Exception):
pass

View File

@@ -3,6 +3,7 @@ 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
@@ -86,6 +87,22 @@ class Exporter:
pass
return open_or_reuse(f, mode=mode, **kwargs)
def get_dfs(self, env):
yield from get_dc_dfs(env.datacollector, trial_id=env.id)
def get_dc_dfs(dc, trial_id=None):
dfs = {
"env": dc.get_model_vars_dataframe(),
"agents": dc.get_agent_vars_dataframe(),
}
for table_name in dc.tables:
dfs[table_name] = dc.get_table_dataframe(table_name)
if trial_id:
for (name, df) in dfs.items():
df["trial_id"] = trial_id
yield from dfs.items()
class default(Exporter):
"""Default exporter. Writes sqlite results, as well as the simulation YAML"""
@@ -98,7 +115,7 @@ class default(Exporter):
with self.output(self.simulation.name + ".dumped.yml") as f:
f.write(self.simulation.to_yaml())
self.dbpath = os.path.join(self.outdir, f"{self.simulation.name}.sqlite")
try_backup(self.dbpath, move=True)
try_backup(self.dbpath, remove=True)
def trial_end(self, env):
if self.dry_run:
@@ -111,24 +128,10 @@ class default(Exporter):
engine = create_engine(f"sqlite:///{self.dbpath}", echo=False)
dc = env.datacollector
for (t, df) in get_dc_dfs(dc, trial_id=env.id):
for (t, df) in self.get_dfs(env):
df.to_sql(t, con=engine, if_exists="append")
def get_dc_dfs(dc, trial_id=None):
dfs = {
"env": dc.get_model_vars_dataframe(),
"agents": dc.get_agent_vars_dataframe(),
}
for table_name in dc.tables:
dfs[table_name] = dc.get_table_dataframe(table_name)
if trial_id:
for (name, df) in dfs.items():
df['trial_id'] = trial_id
yield from dfs.items()
class csv(Exporter):
"""Export the state of each environment (and its agents) in a separate CSV file"""
@@ -139,7 +142,7 @@ class csv(Exporter):
self.simulation.name, env.id, self.outdir
)
):
for (df_name, df) in get_dc_dfs(env.datacollector, trial_id=env.id):
for (df_name, df) in self.get_dfs(env):
with self.output("{}.{}.csv".format(env.id, df_name)) as f:
df.to_csv(f)
@@ -192,52 +195,19 @@ class graphdrawing(Exporter):
f.savefig(f)
"""
Convert an environment into a NetworkX graph
"""
class summary(Exporter):
"""Print a summary of each trial to sys.stdout"""
def env_to_graph(env, history=None):
G = nx.Graph(env.G)
for agent in env.network_agents:
attributes = {"agent": str(agent.__class__)}
lastattributes = {}
spells = []
lastvisible = False
laststep = None
if not history:
history = sorted(list(env.state_to_tuples()))
for _, t_step, attribute, value in history:
if attribute == "visible":
nowvisible = value
if nowvisible and not lastvisible:
laststep = t_step
if not nowvisible and lastvisible:
spells.append((laststep, t_step))
lastvisible = nowvisible
def trial_end(self, env):
for (t, df) in self.get_dfs(env):
if not len(df):
continue
key = "attr_" + attribute
if key not in attributes:
attributes[key] = list()
if key not in lastattributes:
lastattributes[key] = (value, t_step)
elif lastattributes[key][0] != value:
last_value, laststep = lastattributes[key]
commit_value = (last_value, laststep, t_step)
if key not in attributes:
attributes[key] = list()
attributes[key].append(commit_value)
lastattributes[key] = (value, t_step)
for k, v in lastattributes.items():
attributes[k].append((v[0], v[1], None))
if lastvisible:
spells.append((laststep, None))
if spells:
G.add_node(agent.id, spells=spells, **attributes)
else:
G.add_node(agent.id, **attributes)
return G
msg = indent(str(df.describe()), " ")
logger.info(
dedent(
f"""
Dataframe {t}:
"""
)
+ msg
)

View File

@@ -65,10 +65,8 @@ def find_unassigned(G, shuffle=False, random=random):
random.shuffle(candidates)
for next_id, data in candidates:
if "agent" not in data:
node_id = next_id
break
return node_id
return next_id
return None
def dump_gexf(G, f):

View File

@@ -21,7 +21,6 @@ import pickle
from . import serialization, exporters, utils, basestring, agents
from .environment import Environment
from .utils import logger, run_and_return_exceptions
from .time import INFINITY
from .config import Config, convert_old
@@ -48,7 +47,7 @@ class Simulation:
max_time: float = float("inf")
max_steps: int = -1
interval: int = 1
num_trials: int = 3
num_trials: int = 1
parallel: Optional[bool] = None
exporters: Optional[List[str]] = field(default_factory=list)
outdir: Optional[str] = None
@@ -194,7 +193,7 @@ class Simulation:
# Set up agents on nodes
def is_done():
return False
return not model.running
if until and hasattr(model.schedule, "time"):
prev = is_done
@@ -226,6 +225,11 @@ Model stats:
f'Simulation time {model.schedule.time}/{until}. Next: {getattr(model.schedule, "next_time", model.schedule.time + self.interval)}'
)
model.step()
if (
model.schedule.time < until
): # Simulation ended (no more steps) before the expected time
model.schedule.time = until
return model
def to_dict(self):

View File

@@ -2,6 +2,10 @@ from mesa.time import BaseScheduler
from queue import Empty
from heapq import heappush, heappop, heapify
import math
from inspect import getsource
from numbers import Number
from .utils import logger
from mesa import Agent as MesaAgent
@@ -9,15 +13,76 @@ 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):
def next(self, time):
return self._time
def abs(self, time):
return self
def __repr__(self):
return str(f"When({self._time})")
def __lt__(self, other):
if isinstance(other, Number):
return self._time < other
return self._time < other.next(self._time)
def __gt__(self, other):
if isinstance(other, Number):
return self._time > other
return self._time > other.next(self._time)
def ready(self, agent):
return self._time <= agent.model.schedule.time
def return_value(self, agent):
return None
class Cond(When):
def __init__(self, func, delta=1, return_func=lambda agent: None):
self._func = func
self._delta = delta
self._checked = False
self._return_func = return_func
def next(self, time):
if self._checked:
return time + self._delta
return time
def abs(self, time):
return self
def ready(self, agent):
self._checked = True
return self._func(agent)
def return_value(self, agent):
return self._return_func(agent)
def __eq__(self, other):
return False
def __lt__(self, other):
return True
def __gt__(self, other):
return False
def __repr__(self):
return str(f'Cond("{getsource(self._func)}")')
NEVER = When(INFINITY)
@@ -27,11 +92,19 @@ class Delta(When):
self._delta = delta
def __eq__(self, other):
return self._delta == other._delta
if isinstance(other, Delta):
return self._delta == other._delta
return False
def abs(self, time):
return When(self._delta + time)
def next(self, time):
return time + self._delta
def __repr__(self):
return str(f"Delta({self._delta})")
class TimedActivation(BaseScheduler):
"""A scheduler which activates each agent when the agent requests.
@@ -47,14 +120,17 @@ class TimedActivation(BaseScheduler):
def add(self, agent: MesaAgent, when=None):
if when is None:
when = self.time
when = When(self.time)
elif not isinstance(when, When):
when = When(when)
if agent.unique_id in self._agents:
self._queue.remove((self._next[agent.unique_id], agent.unique_id))
del self._agents[agent.unique_id]
heapify(self._queue)
if agent.unique_id in self._next:
self._queue.remove((self._next[agent.unique_id], agent))
heapify(self._queue)
heappush(self._queue, (when, agent.unique_id))
self._next[agent.unique_id] = when
heappush(self._queue, (when, agent))
super().add(agent)
def step(self) -> None:
@@ -63,42 +139,77 @@ class TimedActivation(BaseScheduler):
an agent will signal when it wants to be scheduled next.
"""
self.logger.debug(f"Simulation step {self.next_time}")
self.logger.debug(f"Simulation step {self.time}")
if not self.model.running:
return
self.time = self.next_time
when = self.time
when = NEVER
while self._queue and self._queue[0][0] == self.time:
(when, agent_id) = heappop(self._queue)
self.logger.debug(f"Stepping agent {agent_id}")
to_process = []
skipped = []
next_time = INFINITY
agent = self._agents[agent_id]
returned = agent.step()
ix = 0
if not getattr(agent, "alive", True):
self.remove(agent)
self.logger.debug(f"Queue length: {len(self._queue)}")
while self._queue:
(when, agent) = self._queue[0]
if when > self.time:
break
heappop(self._queue)
if when.ready(agent):
try:
agent._last_return = when.return_value(agent)
except Exception as ex:
agent._last_except = ex
self._next.pop(agent.unique_id, None)
to_process.append(agent)
continue
when = (returned or Delta(1)).abs(self.time)
if when < self.time:
raise Exception(
"Cannot schedule an agent for a time in the past ({} < {})".format(
when, self.time
)
)
next_time = min(next_time, when.next(self.time))
self._next[agent.unique_id] = next_time
skipped.append((when, agent))
self._next[agent_id] = when
heappush(self._queue, (when, agent_id))
if self._queue:
next_time = min(next_time, self._queue[0][0].next(self.time))
self._queue = [*skipped, *self._queue]
for agent in to_process:
self.logger.debug(f"Stepping agent {agent}")
try:
returned = ((agent.step() or Delta(1))).abs(self.time)
except DeadAgent:
if agent.unique_id in self._next:
del self._next[agent.unique_id]
agent.alive = False
continue
if not getattr(agent, "alive", True):
continue
value = returned.next(self.time)
agent._last_return = value
if value < self.time:
raise Exception(
f"Cannot schedule an agent for a time in the past ({when} < {self.time})"
)
if value < INFINITY:
next_time = min(value, next_time)
self._next[agent.unique_id] = returned
heappush(self._queue, (returned, agent))
else:
assert not self._next[agent.unique_id]
self.steps += 1
self.logger.debug(f"Updating time step: {self.time} -> {next_time}")
self.time = next_time
if not self._queue:
self.time = INFINITY
self.next_time = INFINITY
if not self._queue or next_time == INFINITY:
self.model.running = False
return self.time
self.next_time = self._queue[0][0]
self.logger.debug(f"Next step: {self.next_time}")

View File

@@ -47,7 +47,7 @@ def timer(name="task", pre="", function=logger.info, to_object=None):
to_object.end = end
def try_backup(path, move=False):
def try_backup(path, remove=False):
if not os.path.exists(path):
return None
outdir = os.path.dirname(path)
@@ -59,9 +59,7 @@ def try_backup(path, move=False):
backup_dir = os.path.join(outdir, "backup")
if not os.path.exists(backup_dir):
os.makedirs(backup_dir)
newpath = os.path.join(
backup_dir, "{}@{}".format(os.path.basename(path), stamp)
)
newpath = os.path.join(backup_dir, "{}@{}".format(os.path.basename(path), stamp))
if move:
move(path, newpath)
else:

View File

@@ -13,14 +13,57 @@ class Dead(agents.FSM):
class TestMain(TestCase):
def test_die_raises_exception(self):
d = Dead(unique_id=0, model=environment.Environment())
d.step()
with pytest.raises(agents.DeadAgent):
d.step()
def test_die_returns_infinity(self):
'''The last step of a dead agent should return time.INFINITY'''
d = Dead(unique_id=0, model=environment.Environment())
ret = d.step().abs(0)
print(ret, "next")
assert ret == stime.INFINITY
assert ret == stime.NEVER
def test_die_raises_exception(self):
'''A dead agent should raise an exception if it is stepped after death'''
d = Dead(unique_id=0, model=environment.Environment())
d.step()
with pytest.raises(stime.DeadAgent):
d.step()
def test_agent_generator(self):
'''
The step function of an agent could be a generator. In that case, the state of the
agent will be resumed after every call to step.
'''
a = 0
class Gen(agents.BaseAgent):
def step(self):
nonlocal a
for i in range(5):
yield
a += 1
e = environment.Environment()
g = Gen(model=e, unique_id=e.next_id())
e.schedule.add(g)
for i in range(5):
e.step()
assert a == i
def test_state_decorator(self):
class MyAgent(agents.FSM):
run = 0
@agents.default_state
@agents.state('original')
def root(self):
self.run += 1
return self.other
@agents.state
def other(self):
self.run += 1
e = environment.Environment()
a = MyAgent(model=e, unique_id=e.next_id())
a.step()
assert a.run == 1
a.step()
assert a.run == 2

View File

@@ -50,7 +50,6 @@ class Exporters(TestCase):
for env in s.run_simulation(exporters=[Dummy], dry_run=True):
assert len(env.agents) == 1
assert env.now == max_time
assert Dummy.started
assert Dummy.ended

View File

@@ -160,32 +160,12 @@ class TestMain(TestCase):
def test_serialize_agent_class(self):
"""A class from soil.agents should be serialized without the module part"""
ser = agents.serialize_type(CustomAgent)
ser = agents._serialize_type(CustomAgent)
assert ser == "test_main.CustomAgent"
ser = agents.serialize_type(agents.BaseAgent)
ser = agents._serialize_type(agents.BaseAgent)
assert ser == "BaseAgent"
pickle.dumps(ser)
def test_deserialize_agent_distribution(self):
agent_distro = [
{"agent_class": "CounterModel", "weight": 1},
{"agent_class": "test_main.CustomAgent", "weight": 2},
]
converted = agents.deserialize_definition(agent_distro)
assert converted[0]["agent_class"] == agents.CounterModel
assert converted[1]["agent_class"] == CustomAgent
pickle.dumps(converted)
def test_serialize_agent_distribution(self):
agent_distro = [
{"agent_class": agents.CounterModel, "weight": 1},
{"agent_class": CustomAgent, "weight": 2},
]
converted = agents.serialize_definition(agent_distro)
assert converted[0]["agent_class"] == "CounterModel"
assert converted[1]["agent_class"] == "test_main.CustomAgent"
pickle.dumps(converted)
def test_templates(self):
"""Loading a template should result in several configs"""
configs = serialization.load_file(join(EXAMPLES, "template.yml"))

74
tests/test_time.py Normal file
View File

@@ -0,0 +1,74 @@
from unittest import TestCase
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):
'''
'''
times_started = []
times_awakened = []
times = []
done = 0
class CondAgent(agents.BaseAgent):
def step(self):
nonlocal done
times_started.append(self.now)
while True:
yield time.Cond(lambda agent: agent.model.schedule.time >= 10)
times_awakened.append(self.now)
if self.now >= 10:
break
done += 1
env = environment.Environment(agents=[{'agent_class': CondAgent}])
while env.schedule.time < 11:
env.step()
times.append(env.now)
assert env.schedule.time == 11
assert times_started == [0]
assert times_awakened == [10]
assert done == 1
# The first time will produce the Cond.
# Since there are no other agents, time will not advance, but the number
# of steps will.
assert env.schedule.steps == 12
assert len(times) == 12
while env.schedule.time < 12:
env.step()
times.append(env.now)
assert env.schedule.time == 12
assert times_started == [0, 11]
assert times_awakened == [10, 11]
assert done == 2
# Once more to yield the cond, another one to continue
assert env.schedule.steps == 14
assert len(times) == 14