1
0
mirror of https://github.com/gsi-upm/soil synced 2024-11-24 11:52:29 +00:00

Big refactor v0.30

All test pass, except for the TestConfig suite, which is not too critical as the
plan for this version onwards is to avoid configuration as much as possible.
This commit is contained in:
J. Fernando Sánchez 2023-04-09 04:19:24 +02:00
parent 2869b1e1e6
commit 73282530fd
45 changed files with 721 additions and 82265 deletions

47
docs/notes_v0.30.rst Normal file
View File

@ -0,0 +1,47 @@
What are the main changes between version 0.3 and 0.2?
######################################################
Version 0.3 is a major rewrite of the Soil system, focused on simplifying the API, aligning it with Mesa, and making it easier to use.
Unfortunately, this comes at the cost of backwards compatibility.
We drew several lessons from the previous version of Soil, and tried to address them in this version.
Mainly:
- The split between simulation configuration and simulation code was overly complicated for most use cases. As a result, most users ended up reusing configuration.
- Storing **all** the simulation data in a database is costly and unnecessary for most use cases. For most use cases, only a handful of variables need to be stored. This fits nicely with Mesa's data collection system.
- The API was too complex, and it was difficult to understand how to use it.
- Most parts of the API were not aligned with Mesa, which made it difficult to use Mesa's features or to integrate Soil modules with Mesa code, especially for newcomers.
- Many parts of the API were tightly coupled, which made it difficult to find bugs, test the system and add new features.
The 0.30 rewrite should provide a middle ground between Soil's opinionated approach and Mesa's flexibility.
The new Soil is less configuration-centric.
It aims to provide more modular and convenient functions, most of which can be used in vanilla Mesa.
How are agents assigned to nodes in the network
###############################################
In principle, the generation of the network topology and the assignment of agents to nodes are two separate processes.
There is a mechanism to initialize the agents, a mechanism to initialize the topology, and a mechanism to assign agents to nodes.
However, there are a myriad of ways to do this, and it is not clear which is the best way to do it.
Earlier versions of Soil approached it by providing a fairly complex method of agent and node generation.
The result was a very complex and difficult to understand system, which is was also prone to bugs and changes between versions.
Starting with version 0.3, the approach is to provide a simplified yet flexible system for generating the network topology and assigning agents to nodes.
This is based on these methods:
- `create_network`
- `add_agents` (and `add_agent`)
- `populate_network`
The default implementation of `soil.Environment` accepts some parameters that will automatically do these steps for the most common case.
All other cases can be handled by overriding the `init(self)` method and explicitly using these methods.
Can Soil environments include more than one network / topology?
###############################################################
Yes, but each network has to be included manually.
Somewhere between 0.20 and 0.30 we included the ability to include multiple networks, but it was deemed too complex and was removed.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +0,0 @@
---
version: '2'
name: simple
group: tests
dir_path: "/tmp/"
num_trials: 3
max_steps: 100
interval: 1
seed: "CompleteSeed!"
model_class: Environment
model_params:
am_i_complete: true
topology:
params:
generator: complete_graph
n: 12
environment:
agents:
agent_class: CounterModel
topology: true
state:
times: 1
# In this group we are not specifying any topology
fixed:
- name: 'Environment Agent 1'
agent_class: BaseAgent
group: environment
topology: false
hidden: true
state:
times: 10
- agent_class: CounterModel
id: 0
group: fixed_counters
state:
times: 1
total: 0
- agent_class: CounterModel
group: fixed_counters
id: 1
distribution:
- agent_class: CounterModel
weight: 1
group: distro_counters
state:
times: 3
- agent_class: AggregatedCounter
weight: 0.2
override:
- filter:
agent_class: AggregatedCounter
n: 2
state:
times: 5

View File

@ -1,16 +0,0 @@
---
name: custom-generator
description: Using a custom generator for the network
num_trials: 3
max_steps: 100
interval: 1
network_params:
generator: mymodule.mygenerator
# These are custom parameters
n: 10
n_edges: 5
network_agents:
- agent_class: CounterModel
weight: 1
state:
state_id: 0

View File

@ -1,6 +1,7 @@
from networkx import Graph
import random
import networkx as nx
from soil import Simulation, Environment, CounterModel, parameters
def mygenerator(n=5, n_edges=5):
@ -20,3 +21,19 @@ def mygenerator(n=5, n_edges=5):
n_out = random.choice(nodes)
G.add_edge(n_in, n_out)
return G
class GeneratorEnv(Environment):
"""Using a custom generator for the network"""
generator: parameters.function = mygenerator
def init(self):
self.create_network(network_generator=self.generator, n=10, n_edges=5)
self.init_agents(CounterModel)
sim = Simulation(model=GeneratorEnv, max_steps=10, interval=1)
if __name__ == '__main__':
sim.run(dry_run=True)

View File

@ -4,8 +4,7 @@ from soil.time import Delta
class Fibonacci(FSM):
"""Agent that only executes in t_steps that are Fibonacci numbers"""
defaults = {"prev": 1}
prev = 1
@default_state
@state
@ -25,23 +24,18 @@ class Odds(FSM):
return None, Delta(1 + self.now % 2)
from soil import Simulation
from soil import Environment, Simulation
from networkx import complete_graph
simulation = Simulation(
model_params={
'agents':[
{'agent_class': Fibonacci, 'node_id': 0},
{'agent_class': Odds, 'node_id': 1}
],
'topology': {
'params': {
'generator': 'complete_graph',
'n': 2
}
},
},
max_time=100,
)
class TimeoutsEnv(Environment):
def init(self):
self.init_network(generator=complete_graph, n=2)
self.add_agent(agent_class=Fibonacci, node_id=0)
self.add_agent(agent_class=Odds, node_id=1)
sim = Simulation(model=TimeoutsEnv, max_steps=10, interval=1)
if __name__ == "__main__":
simulation.run(dry_run=True)
sim.run(dry_run=True)

View File

@ -232,12 +232,10 @@ class Passenger(Evented, FSM):
self.die()
simulation = Simulation(
name="RideHailing",
model_class=City,
model_params={"n_passengers": 2},
simulation = Simulation(name="RideHailing",
model=City,
seed="carsSeed",
)
model_params=dict(n_passengers=2))
if __name__ == "__main__":
simulation.run()

View File

@ -1,19 +0,0 @@
---
name: mesa_sim
group: tests
dir_path: "/tmp"
num_trials: 3
max_steps: 100
interval: 1
seed: '1'
model_class: social_wealth.MoneyEnv
model_params:
generator: social_wealth.graph_generator
agents:
topology: true
distribution:
- agent_class: social_wealth.SocialMoneyAgent
weight: 1
N: 10
width: 50
height: 50

View File

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

View File

@ -1,5 +1,5 @@
from mesa.visualization.ModularVisualization import ModularServer
from soil.visualization import UserSettableParameter
from mesa.visualization.UserParam import Slider, Choice
from mesa.visualization.modules import ChartModule, NetworkModule, CanvasGrid
from social_wealth import MoneyEnv, graph_generator, SocialMoneyAgent
import networkx as nx
@ -64,8 +64,7 @@ chart = ChartModule(
)
model_params = {
"N": UserSettableParameter(
"slider",
"N": Slider(
"N",
5,
1,
@ -73,8 +72,7 @@ model_params = {
1,
description="Choose how many agents to include in the model",
),
"height": UserSettableParameter(
"slider",
"height": Slider(
"height",
5,
5,
@ -82,8 +80,7 @@ model_params = {
1,
description="Grid height",
),
"width": UserSettableParameter(
"slider",
"width": Slider(
"width",
5,
5,
@ -91,8 +88,7 @@ model_params = {
1,
description="Grid width",
),
"agent_class": UserSettableParameter(
"choice",
"agent_class": Choice(
"Agent class",
value="MoneyAgent",
choices=["MoneyAgent", "SocialMoneyAgent"],

View File

@ -1,133 +0,0 @@
---
default_state: {}
environment_agents: []
environment_params:
prob_neighbor_spread: 0.0
prob_tv_spread: 0.01
interval: 1
max_steps: 300
name: Sim_all_dumb
network_agents:
- agent_class: newsspread.DumbViewer
state:
has_tv: false
weight: 1
- agent_class: newsspread.DumbViewer
state:
has_tv: true
weight: 1
network_params:
generator: barabasi_albert_graph
n: 500
m: 5
num_trials: 50
---
default_state: {}
environment_agents: []
environment_params:
prob_neighbor_spread: 0.0
prob_tv_spread: 0.01
interval: 1
max_steps: 300
name: Sim_half_herd
network_agents:
- agent_class: newsspread.DumbViewer
state:
has_tv: false
weight: 1
- agent_class: newsspread.DumbViewer
state:
has_tv: true
weight: 1
- agent_class: newsspread.HerdViewer
state:
has_tv: false
weight: 1
- agent_class: newsspread.HerdViewer
state:
has_tv: true
weight: 1
network_params:
generator: barabasi_albert_graph
n: 500
m: 5
num_trials: 50
---
default_state: {}
environment_agents: []
environment_params:
prob_neighbor_spread: 0.0
prob_tv_spread: 0.01
interval: 1
max_steps: 300
name: Sim_all_herd
network_agents:
- agent_class: newsspread.HerdViewer
state:
has_tv: true
state_id: neutral
weight: 1
- agent_class: newsspread.HerdViewer
state:
has_tv: true
state_id: neutral
weight: 1
network_params:
generator: barabasi_albert_graph
n: 500
m: 5
num_trials: 50
---
default_state: {}
environment_agents: []
environment_params:
prob_neighbor_spread: 0.0
prob_tv_spread: 0.01
prob_neighbor_cure: 0.1
interval: 1
max_steps: 300
name: Sim_wise_herd
network_agents:
- agent_class: newsspread.HerdViewer
state:
has_tv: true
state_id: neutral
weight: 1
- agent_class: newsspread.WiseViewer
state:
has_tv: true
weight: 1
network_params:
generator: barabasi_albert_graph
n: 500
m: 5
num_trials: 50
---
default_state: {}
environment_agents: []
environment_params:
prob_neighbor_spread: 0.0
prob_tv_spread: 0.01
prob_neighbor_cure: 0.1
interval: 1
max_steps: 300
name: Sim_all_wise
network_agents:
- agent_class: newsspread.WiseViewer
state:
has_tv: true
state_id: neutral
weight: 1
- agent_class: newsspread.WiseViewer
state:
has_tv: true
weight: 1
network_params:
generator: barabasi_albert_graph
n: 500
m: 5
network_params:
generator: barabasi_albert_graph
n: 500
m: 5
num_trials: 50

View File

@ -1,87 +0,0 @@
from soil.agents import FSM, NetworkAgent, state, default_state, prob
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.
"""
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"]):
return self.infected
if self.has_been_infected:
return self.infected
@state
def infected(self):
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.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_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.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,
}
@state
def cured(self):
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))
def cure(self):
self.has_been_cured = True
@state
def infected(self):
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

View File

@ -0,0 +1,129 @@
from soil.agents import FSM, NetworkAgent, state, default_state, prob
from soil.parameters import *
import logging
from soil.environment import Environment
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.
"""
has_been_infected: bool = False
has_tv: bool = False
@default_state
@state
def neutral(self):
if self.has_tv:
if self.prob(self.get("prob_tv_spread")):
return self.infected
if self.has_been_infected:
return self.infected
@state
def infected(self):
for neighbor in self.get_neighbors(state_id=self.neutral.id):
if self.prob(self.get("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.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_neighbors(state_id=self.infected.id)
total = self.count_neighbors()
prob_infect = self.get("prob_neighbor_spread") * infected / total
self.debug("prob_infect", prob_infect)
if self.prob(prob_infect):
self.has_been_infected = True
class WiseViewer(HerdViewer):
"""
A viewer that can change its mind.
"""
@state
def cured(self):
prob_cure = self.get("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))
def cure(self):
self.has_been_cured = True
@state
def infected(self):
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.get("prob_neighbor_cure") * (cured / infected)
if self.prob(prob_cure):
return self.cured
class NewsSpread(Environment):
ratio_dumb: probability = 1,
ratio_herd: probability = 0,
ratio_wise: probability = 0,
prob_tv_spread: probability = 0.1,
prob_neighbor_spread: probability = 0.1,
prob_neighbor_cure: probability = 0.05,
def init(self):
self.populate_network([DumbViewer, HerdViewer, WiseViewer], [self.ratio_dumb, self.ratio_herd, self.ratio_wise])
from itertools import permutations
from soil import Simulation
# We want to investigate the effect of different agent distributions on the spread of news.
# To do that, we will run different simulations, with a varying ratio of DumbViewers, HerdViewers, and WiseViewers
# Because the effect of these agents might also depend on the network structure, we will run our simulations on two different networks:
# one with a small-world structure and one with a connected structure.
for [r1, r2, r3] in permutations([0, 0.5, 1.0], 3):
for (generator, netparams) in {
"barabasi_albert_graph": {"m": 5},
"erdos_renyi_graph": {"p": 0.1},
}.items():
print(r1, r2, r3, generator)
# Create new simulation
netparams["n"] = 500
sim = Simulation(
model=NewsSpread,
model_params={
"ratio_dumb": r1,
"ratio_herd": r2,
"ratio_wise": r3,
"network_generator": generator,
"network_params": netparams,
"prob_neighbor_spread": 0,
},
num_trials=50,
max_steps=300,
dry_run=True,
)
# Run all the necessary instances
sim.run()

View File

@ -1,7 +1,7 @@
"""
Example of a fully programmatic simulation, without definition files.
"""
from soil import Simulation, agents
from soil import Simulation, Environment, agents
from networkx import Graph
import logging
@ -25,23 +25,18 @@ class MyAgent(agents.FSM):
self.info("This runs 2/10 times on average")
class ProgrammaticEnv(Environment):
def init(self):
self.create_network(generator=mygenerator)
self.populate_network(agent_class=MyAgent)
self.add_agent_reporter('times_run')
simulation = Simulation(
name="Programmatic",
model_params={
'topology': {
'params': {
'generator': mygenerator
},
},
'agents': {
'distribution': [{
'agent_class': MyAgent,
'topology': True,
}]
}
},
model=ProgrammaticEnv,
seed='Program',
agent_reporters={'times_run': 'times_run'},
num_trials=1,
max_time=100,
dry_run=True,

View File

@ -1,26 +0,0 @@
---
name: pubcrawl
num_trials: 3
max_steps: 10
dump: false
network_params:
# Generate 100 empty nodes. They will be assigned a network agent
generator: empty_graph
n: 30
network_agents:
- agent_class: pubcrawl.Patron
description: Extroverted patron
state:
openness: 1.0
weight: 9
- agent_class: pubcrawl.Patron
description: Introverted patron
state:
openness: 0.1
weight: 1
environment_agents:
- agent_class: pubcrawl.Police
environment_class: pubcrawl.CityPubs
environment_params:
altercations: 0
number_of_pubs: 3

View File

@ -1,6 +1,7 @@
from soil.agents import FSM, NetworkAgent, state, default_state
from soil import Environment
from soil import Environment, Simulation, parameters
from itertools import islice
import networkx as nx
import logging
@ -8,19 +9,23 @@ class CityPubs(Environment):
"""Environment with Pubs"""
level = logging.INFO
number_of_pubs: parameters.Integer = 3
ratio_extroverted: parameters.probability = 0.1
pub_capacity: parameters.Integer = 10
def __init__(self, *args, number_of_pubs=3, pub_capacity=10, **kwargs):
super(CityPubs, self).__init__(*args, **kwargs)
def init(self):
pubs = {}
for i in range(number_of_pubs):
for i in range(self.number_of_pubs):
newpub = {
"name": "The awesome pub #{}".format(i),
"open": True,
"capacity": pub_capacity,
"capacity": self.pub_capacity,
"occupancy": 0,
}
pubs[newpub["name"]] = newpub
self.add_agent(agent_class=Police, node_id=0)
self["pubs"] = pubs
self.populate_network([{"openness": 0.1}, {"openness": 1}], [self.ratio_extroverted, 1-self.ratio_extroverted], agent_class=Patron)
def enter(self, pub_id, *nodes):
"""Agents will try to enter. The pub checks if it is possible"""
@ -169,7 +174,20 @@ class Police(FSM):
self.info("No trash to take out. Too bad.")
if __name__ == "__main__":
from soil import run_from_config
sim = Simulation(
name="pubcrawl",
num_trials=3,
max_steps=10,
dry_run=True,
model_params=dict(
generator=nx.empty_graph,
network_params={"n": 30},
model=CityPubs,
altercations=0,
number_of_pubs=3,
)
)
run_from_config("pubcrawl.yml", dry_run=True, dump=None, parallel=False)
if __name__ == "__main__":
sim.run(parallel=False)

View File

@ -1,42 +0,0 @@
---
version: '2'
name: rabbits_basic
num_trials: 1
seed: MySeed
description: null
group: null
interval: 1.0
max_time: 100
model_class: rabbit_agents.RabbitEnv
model_params:
agents:
topology: true
distribution:
- agent_class: rabbit_agents.Male
weight: 1
- agent_class: rabbit_agents.Female
weight: 1
fixed:
- agent_class: rabbit_agents.RandomAccident
topology: false
hidden: true
state:
group: environment
state:
group: network
mating_prob: 0.1
prob_death: 0.001
topology:
fixed:
directed: true
links: []
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 +0,0 @@
---
version: '2'
name: rabbits_improved
num_trials: 1
seed: MySeed
description: null
group: null
interval: 1.0
max_time: 100
model_class: rabbit_agents.RabbitEnv
model_params:
agents:
topology: true
distribution:
- agent_class: rabbit_agents.Male
weight: 1
- agent_class: rabbit_agents.Female
weight: 1
fixed:
- agent_class: rabbit_agents.RandomAccident
topology: false
hidden: true
state:
group: environment
state:
group: network
mating_prob: 0.1
prob_death: 0.001
topology:
fixed:
directed: true
links: []
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,23 +1,20 @@
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, Simulation
from soil.time import Delta
from enum import Enum
from collections import Counter
import logging
import math
from rabbits_basic_sim import RabbitEnv
class RabbitEnv(Environment):
@property
def num_rabbits(self):
return self.count_agents(agent_class=Rabbit)
@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 RabbitsImprovedEnv(RabbitEnv):
def init(self):
"""Initialize the environment with the new versions of the agents"""
a1 = self.add_node(Male)
a2 = self.add_node(Female)
a1.add_edge(a2)
self.add_agent(RandomAccident)
class Rabbit(FSM, NetworkAgent):
@ -150,8 +147,7 @@ class RandomAccident(BaseAgent):
self.debug("Rabbits alive: {}".format(rabbits_alive))
if __name__ == "__main__":
from soil import easy
sim = Simulation(model=RabbitsImprovedEnv, max_time=100, seed="MySeed", num_trials=1)
with easy("rabbits.yml") as sim:
if __name__ == "__main__":
sim.run()

View File

@ -1,20 +1,29 @@
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, Simulation, report, parameters as params
from collections import Counter
import logging
import math
class RabbitEnv(Environment):
prob_death = 1e-100
prob_death: params.probability = 1e-100
def init(self):
a1 = self.add_node(Male)
a2 = self.add_node(Female)
a1.add_edge(a2)
self.add_agent(RandomAccident)
@report
@property
def num_rabbits(self):
return self.count_agents(agent_class=Rabbit)
@report
@property
def num_males(self):
return self.count_agents(agent_class=Male)
@report
@property
def num_females(self):
return self.count_agents(agent_class=Female)
@ -145,8 +154,8 @@ class RandomAccident(BaseAgent):
self.debug("Rabbits alive: {}".format(rabbits_alive))
if __name__ == "__main__":
from soil import easy
with easy("rabbits.yml") as sim:
sim = Simulation(model=RabbitEnv, max_time=100, seed="MySeed", num_trials=1)
if __name__ == "__main__":
sim.run()

View File

@ -2,7 +2,7 @@
Example of setting a
Example of a fully programmatic simulation, without definition files.
"""
from soil import Simulation, agents
from soil import Simulation, agents, Environment
from soil.time import Delta
@ -29,11 +29,15 @@ class MyAgent(agents.FSM):
return None, Delta(self.random.expovariate(1 / 16))
class RandomEnv(Environment):
def init(self):
self.add_agent(agent_class=MyAgent)
s = Simulation(
name="Programmatic",
model_params={
'agents': [{'agent_class': MyAgent}],
},
model=RandomEnv,
num_trials=1,
max_time=100,
dry_run=True,

View File

@ -1,30 +0,0 @@
---
sampler:
method: "SALib.sample.morris.sample"
N: 10
template:
group: simple
num_trials: 1
interval: 1
max_steps: 2
seed: "CompleteSeed!"
dump: false
model_params:
network_params:
generator: complete_graph
n: 10
network_agents:
- agent_class: CounterModel
weight: "{{ x1 }}"
state:
state_id: 0
- agent_class: AggregatedCounter
weight: "{{ 1 - x1 }}"
name: "{{ x3 }}"
skip_test: true
vars:
bounds:
x1: [0, 1]
x2: [1, 2]
fixed:
x3: ["a", "b", "c"]

View File

@ -1,62 +0,0 @@
name: TerroristNetworkModel_sim
max_steps: 150
num_trials: 1
model_params:
network_params:
generator: random_geometric_graph
radius: 0.2
# generator: geographical_threshold_graph
# theta: 20
n: 100
network_agents:
- agent_class: TerroristNetworkModel.TerroristNetworkModel
weight: 0.8
state:
id: civilian # Civilians
- agent_class: TerroristNetworkModel.TerroristNetworkModel
weight: 0.1
state:
id: leader # Leaders
- agent_class: TerroristNetworkModel.TrainingAreaModel
weight: 0.05
state:
id: terrorist # Terrorism
- agent_class: TerroristNetworkModel.HavenModel
weight: 0.05
state:
id: civilian # Civilian
# TerroristSpreadModel
information_spread_intensity: 0.7
terrorist_additional_influence: 0.035
max_vulnerability: 0.7
prob_interaction: 0.5
# TrainingAreaModel and HavenModel
training_influence: 0.20
haven_influence: 0.20
# TerroristNetworkModel
vision_range: 0.30
sphere_influence: 2
weight_social_distance: 0.035
weight_link_distance: 0.035
visualization_params:
# Icons downloaded from https://www.iconfinder.com/
shape_property: agent
shapes:
TrainingAreaModel: target
HavenModel: home
TerroristNetworkModel: person
colors:
- attr_id: civilian
color: '#40de40'
- attr_id: terrorist
color: red
- attr_id: leader
color: '#c16a6a'
background_image: 'map_4800x2860.jpg'
background_opacity: '0.9'
background_filter_color: 'blue'
skip_test: true # This simulation takes too long for automated tests.

View File

@ -1,6 +1,43 @@
import networkx as nx
from soil.agents import Geo, NetworkAgent, FSM, state, default_state
from soil import Environment
from soil.agents import Geo, NetworkAgent, FSM, custom, state, default_state
from soil import Environment, Simulation
from soil.parameters import *
class TerroristEnvironment(Environment):
generator: function = nx.random_geometric_graph
n: Integer = 100
radius: Float = 0.2
information_spread_intensity: probability = 0.7
terrorist_additional_influence: probability = 0.03
terrorist_additional_influence: probability = 0.035
max_vulnerability: probability = 0.7
prob_interaction: probability = 0.5
# TrainingAreaModel and HavenModel
training_influence: probability = 0.20
haven_influence: probability = 0.20
# TerroristNetworkModel
vision_range: Float = 0.30
sphere_influence: Integer = 2
weight_social_distance: Float = 0.035
weight_link_distance: Float = 0.035
ratio_civil: probability = 0.8
ratio_leader: probability = 0.1
ratio_training: probability = 0.05
ratio_haven: probability = 0.05
def init(self):
self.create_network(generator=self.generator, n=self.n, radius=self.radius)
self.populate_network([
TerroristNetworkModel.w(state_id='civilian'),
TerroristNetworkModel.w(state_id='leader'),
TrainingAreaModel,
HavenModel
], [self.ratio_civil, self.ratio_leader, self.ratio_trainig, self.ratio_heaven])
class TerroristSpreadModel(FSM, Geo):
@ -17,36 +54,21 @@ class TerroristSpreadModel(FSM, Geo):
prob_interaction
"""
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"]
if self["id"] == self.civilian.id: # Civilian
self.mean_belief = self.random.uniform(0.00, 0.5)
elif self["id"] == self.terrorist.id: # Terrorist
def init(self):
if self.state_id == self.civilian.id: # Civilian
self.mean_belief = self.model.random.uniform(0.00, 0.5)
elif self.state_id == self.terrorist.id: # Terrorist
self.mean_belief = self.random.uniform(0.8, 1.00)
elif self["id"] == self.leader.id: # Leader
elif self.state_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"]
self.get("min_vulnerability", 0), self.get("max_vulnerability", 1)
)
@default_state
@state
def civilian(self):
neighbours = list(self.get_neighbors(agent_class=TerroristSpreadModel))
@ -287,3 +309,32 @@ class TerroristNetworkModel(TerroristSpreadModel):
return nx.shortest_path_length(self.G, self.id, target)
except nx.NetworkXNoPath:
return float("inf")
sim = Simulation(
model=TerroristEnvironment,
num_trials=1,
name="TerroristNetworkModel_sim",
max_steps=150,
skip_test=True,
dry_run=True,
)
# TODO: integrate visualization
# visualization_params:
# # Icons downloaded from https://www.iconfinder.com/
# shape_property: agent
# shapes:
# TrainingAreaModel: target
# HavenModel: home
# TerroristNetworkModel: person
# colors:
# - attr_id: civilian
# color: '#40de40'
# - attr_id: terrorist
# color: red
# - attr_id: leader
# color: '#c16a6a'
# background_image: 'map_4800x2860.jpg'
# background_opacity: '0.9'
# background_filter_color: 'blue'

View File

@ -1,15 +0,0 @@
---
name: torvalds_example
max_steps: 10
interval: 2
model_params:
agent_class: CounterModel
default_state:
skill_level: 'beginner'
network_params:
path: 'torvalds.edgelist'
states:
Torvalds:
skill_level: 'God'
balkian:
skill_level: 'developer'

16
examples/torvalds_sim.py Normal file
View File

@ -0,0 +1,16 @@
from soil import Environment, Simulation, CounterModel
class TorvaldsEnv(Environment):
def init(self):
self.create_network(path='torvalds.edgelist')
self.populate_network(CounterModel, skill_level='beginner')
print("Agentes: ", list(self.network_agents))
self.find_one(node_id="Torvalds").skill_level = 'God'
self.find_one(node_id="balkian").skill_level = 'developer'
sim = Simulation(name='torvalds_example',
max_steps=10,
interval=2,
model=TorvaldsEnv)

View File

@ -5,6 +5,8 @@ pyyaml>=5.1
pandas>=1
SALib>=1.3
Jinja2
Mesa>=1.1
Mesa>=1.2
pydantic>=1.9
sqlalchemy>=1.4
typing-extensions>=4.4
annotated-types>=0.4

View File

@ -24,6 +24,7 @@ from .datacollection import SoilCollector
from . import serialization
from .utils import logger
from .time import *
from .decorators import *
def main(
@ -184,7 +185,7 @@ def main(
return
sims = list(
simulation.iter_from_config(
simulation.iter_from_file(
args.file,
dry_run=args.dry_run,
exporters=exporters,

View File

@ -1,6 +1,12 @@
from . import NetworkAgent
from . import BaseAgent, NetworkAgent
class Ticker(BaseAgent):
times = 0
def step(self):
self.times += 1
class CounterModel(NetworkAgent):
"""
Dummy behaviour. It counts the number of nodes in the network and neighbors

View File

@ -14,10 +14,10 @@ import networkx as nx
from typing import Any
from mesa import Agent as MesaAgent
from mesa import Agent as MesaAgent, Model
from typing import Dict, List
from .. import serialization, utils, time, config
from .. import serialization, network, utils, time, config
IGNORED_FIELDS = ("model", "logger")
@ -123,11 +123,19 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def prob(self, probability):
return prob(probability, self.model.random)
@classmethod
def w(cls, **kwargs):
return custom(cls, **kwargs)
# TODO: refactor to clean up mesa compatibility
@property
def id(self):
return self.unique_id
@id.setter
def id(self, value):
self.unique_id = value
@classmethod
def from_dict(cls, model, attrs, warn_extra=True):
ignored = {}
@ -175,7 +183,11 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
return it
def get(self, key, default=None):
return self[key] if key in self else default
if key in self:
return self[key]
elif key in self.model:
return self.model[key]
return default
@property
def now(self):
@ -621,12 +633,16 @@ def _from_distro(
from .network_agents import *
from .fsm import *
from .evented import *
from typing import Optional
class Agent(NetworkAgent, FSM, EventedAgent):
"""Default agent class, has both network and event capabilities"""
from ..environment import NetworkEnvironment
from .BassModel import *
from .IndependentCascadeModel import *
from .SISaModel import *
@ -640,3 +656,8 @@ except ImportError:
import sys
print("Could not load the Geo Agent, scipy is not installed", file=sys.stderr)
def custom(cls, **kwargs):
"""Create a new class from a template class and keyword arguments"""
return type(cls.__name__, (cls,), kwargs)

View File

@ -38,8 +38,9 @@ class NetworkAgent(BaseAgent):
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)
agent = self.G.nodes[node_id].get("agent")
if agent is not None:
neighbor_ids.add(agent.id)
if unique_ids:
unique_ids = unique_ids & neighbor_ids
else:

4
soil/decorators.py Normal file
View File

@ -0,0 +1,4 @@
def report(f: property):
print(f.fget)
setattr(f.fget, "add_to_report", True)
return f

View File

@ -6,20 +6,22 @@ import math
import logging
import inspect
from typing import Any, Dict, Optional, Union, List
from typing import Any, Callable, Dict, Optional, Union, List, Type
from collections import namedtuple
from time import time as current_time
from copy import deepcopy
from networkx.readwrite import json_graph
import networkx as nx
from mesa import Model
from mesa import Model, Agent
from . import agents as agentmod, config, datacollection, serialization, utils, time, network, events
from . import agents as agentmod, datacollection, serialization, utils, time, network, events
# TODO: add metaclass to read attributes of a model
# TODO: read "report" attributes from the model
class BaseEnvironment(Model):
"""
The environment is key in a simulation. It controls how agents interact,
@ -33,29 +35,35 @@ class BaseEnvironment(Model):
:meth:`soil.environment.Environment.get` method.
"""
def __new__(cls, *args: Any, seed="default", dir_path=None, **kwargs: Any) -> Any:
"""Create a new model with a default seed value"""
self = super().__new__(cls, *args, seed=seed, **kwargs)
self.dir_path = dir_path or os.getcwd()
return self
def __init__(
self,
*,
id="unnamed_env",
seed="default",
schedule_class=time.TimedActivation,
dir_path=None,
schedule_class=time.TimedActivation,
interval=1,
agent_class=None,
agents: List[tuple[type, Dict[str, Any]]] = {},
agents: Optional[Dict] = None,
collector_class: type = datacollection.SoilCollector,
agent_reporters: Optional[Any] = None,
model_reporters: Optional[Any] = None,
tables: Optional[Any] = None,
init: bool = True,
**env_params,
):
super().__init__(seed=seed)
super().__init__()
self.current_id = -1
self.id = id
self.dir_path = dir_path or os.getcwd()
if schedule_class is None:
schedule_class = time.TimedActivation
@ -63,10 +71,7 @@ class BaseEnvironment(Model):
schedule_class = serialization.deserialize(schedule_class)
self.schedule = schedule_class(self)
self.agent_class = agent_class or agentmod.BaseAgent
self.interval = interval
self.init_agents(agents)
self.logger = utils.logger.getChild(self.id)
@ -79,53 +84,13 @@ class BaseEnvironment(Model):
for (k, v) in env_params.items():
self[k] = v
def _agent_from_dict(self, agent):
"""
Translate an agent dictionary into an agent
"""
agent = dict(**agent)
cls = agent.pop("agent_class", None) or self.agent_class
unique_id = agent.pop("unique_id", None)
if unique_id is None:
unique_id = self.next_id()
if agents:
self.add_agents(**agents)
if init:
self.init()
return serialization.deserialize(cls)(unique_id=unique_id, model=self, **agent)
def init_agents(self, agents: Union[config.AgentConfig, List[Dict[str, Any]]] = {}):
"""
Initialize the agents in the model from either a `soil.config.AgentConfig` or a list of
dictionaries that each describes an agent.
If given a list of dictionaries, an agent will be created for each dictionary. The agent
class can be specified through the `agent_class` key. The rest of the items will be used
as parameters to the agent.
"""
if not agents:
return
lst = agents
override = []
if not isinstance(lst, list):
if not isinstance(agents, config.AgentConfig):
lst = config.AgentConfig(**agents)
if lst.override:
override = lst.override
lst = self._agent_dict_from_config(lst)
# TODO: check override is working again. It cannot (easily) be part of agents.from_config anymore,
# because it needs attribute such as unique_id, which are only present after init
new_agents = [self._agent_from_dict(agent) for agent in lst]
for a in new_agents:
self.schedule.add(a)
for rule in override:
for agent in agentmod.filter_agents(self.schedule._agents, **rule.filter):
for attr, value in rule.state.items():
setattr(agent, attr, value)
def _agent_dict_from_config(self, cfg):
return agentmod.from_config(cfg, random=self.random)
def init(self):
pass
@property
def agents(self):
@ -145,16 +110,29 @@ class BaseEnvironment(Model):
"The environment has not been scheduled, so it has no sense of time"
)
def add_agent(self, unique_id=None, **kwargs):
def add_agent(self, agent_class, unique_id=None, **agent):
if unique_id is None:
unique_id = self.next_id()
kwargs["unique_id"] = unique_id
a = self._agent_from_dict(kwargs)
agent["unique_id"] = unique_id
agent = dict(**agent)
unique_id = agent.pop("unique_id", None)
if unique_id is None:
unique_id = self.next_id()
a = serialization.deserialize(agent_class)(unique_id=unique_id, model=self, **agent)
self.schedule.add(a)
return a
def add_agents(self, agent_classes: List[type], k, weights: Optional[List[float]] = None, **kwargs):
if weights is None:
weights = [1] * len(agent_classes)
for cls in self.random.choices(agent_classes, weights=weights, k=k):
self.add_agent(agent_class=cls, **kwargs)
def log(self, message, *args, level=logging.INFO, **kwargs):
if not self.logger.isEnabledFor(level):
return
@ -215,61 +193,58 @@ class NetworkEnvironment(BaseEnvironment):
"""
def __init__(
self, *args, topology: Union[config.NetConfig, nx.Graph] = None, **kwargs
self, *args,
topology: Optional[Union[nx.Graph, str]] = None,
agent_class: Optional[Type[agentmod.Agent]] = None,
network_generator: Optional[Callable] = None,
network_params: Optional[Dict] = None, **kwargs
):
agents = kwargs.pop("agents", None)
super().__init__(*args, agents=None, **kwargs)
self.topology = topology
self.network_generator = network_generator
self.network_params = network_params
if topology or network_params or network_generator:
self.create_network(topology, network_params=network_params, network_generator=network_generator)
else:
self.G = nx.Graph()
super().__init__(*args, **kwargs, init=False)
if topology is None:
topology = nx.Graph()
elif not isinstance(topology, nx.Graph):
topology = network.from_config(topology, dir_path=self.dir_path)
self.agent_class = agent_class
if agent_class:
self.agent_class = serialization.deserialize(agent_class)
self.init()
if self.agent_class:
self.populate_network(self.agent_class)
def add_agents(self, *args, k=None, **kwargs):
if not k and not self.G:
raise ValueError("Cannot add agents to an empty network")
super().add_agents(*args, k=k or len(self.G), **kwargs)
def create_network(self, topology=None, network_generator=None, path=None, network_params=None):
if topology is not None:
topology = network.from_topology(topology, dir_path=self.dir_path)
elif path is not None:
topology = network.from_topology(path, dir_path=self.dir_path)
elif network_generator is not None:
topology = network.from_params(network_generator, dir_path=self.dir_path, **network_params)
else:
raise ValueError("topology must be a networkx.Graph or a string, or network_generator must be provided")
self.G = topology
self.init_agents(agents)
def init_agents(self, *args, **kwargs):
"""Initialize the agents from a"""
super().init_agents(*args, **kwargs)
for agent in self.schedule._agents.values():
self._init_node(agent)
self._assign_node(agent)
def _init_node(self, agent):
def _assign_node(self, agent):
"""
Make sure the node for a given agent has the proper attributes.
"""
if hasattr(agent, "node_id"):
self.G.nodes[agent.node_id]["agent"] = agent
def _agent_dict_from_config(self, cfg):
return agentmod.from_config(cfg, topology=self.G, random=self.random)
def _agent_from_dict(self, agent, unique_id=None):
agent = dict(agent)
if not agent.get("topology", False):
return super()._agent_from_dict(agent)
if unique_id is None:
unique_id = self.next_id()
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
node_attrs = self.G.nodes[node_id]
node_attrs.pop('agent', None)
node_attrs.update(agent)
agent = node_attrs
a = super()._agent_from_dict(agent)
self._init_node(a)
return a
@property
def network_agents(self):
for a in self.schedule._agents.values():
@ -302,24 +277,37 @@ class NetworkEnvironment(BaseEnvironment):
a["visible"] = True
return a
def add_agent(self, *args, **kwargs):
a = super().add_agent(*args, **kwargs)
def add_agent(self, agent_class, *args, **kwargs):
if issubclass(agent_class, agentmod.NetworkAgent) and "node_id" not in kwargs:
return self.add_node(agent_class, *args, **kwargs)
a = super().add_agent(agent_class, *args, **kwargs)
if hasattr(a, "node_id"):
assert self.G.nodes[a.node_id]["agent"] == a
assigned = self.G.nodes[a.node_id].get("agent")
if not assigned:
self.G.nodes[a.node_id]["agent"] = a
elif assigned != a:
raise ValueError(f"Node {a.node_id} already has an agent assigned: {assigned}")
return a
def agent_for_node_id(self, node_id):
return self.G.nodes[node_id].get("agent")
def populate_network(self, agent_class, weights=None, **agent_params):
if not hasattr(agent_class, "len"):
def populate_network(self, agent_class: List[Model], weights: List[float] = None, **agent_params):
if isinstance(agent_class, type):
agent_class = [agent_class]
weights = None
for (node_id, node) in self.G.nodes(data=True):
else:
agent_class = list(agent_class)
if not weights:
weights = [1] * len(agent_class)
assert len(self.G)
classes = self.random.choices(agent_class, weights, k=len(self.G))
for (cls, (node_id, node)) in zip(classes, self.G.nodes(data=True)):
if "agent" in node:
continue
a_class = self.random.choices(agent_class, weights)[0]
self.add_agent(node_id=node_id, topology=self.G, agent_class=a_class, **agent_params)
a = self.add_agent(node_id=node_id, topology=self.G, agent_class=cls, **agent_params)
node["agent"] = a
assert all("agent" in node for (_, node) in self.G.nodes(data=True))
assert len(list(self.network_agents))
class EventedEnvironment(BaseEnvironment):

View File

@ -10,12 +10,21 @@ import networkx as nx
from . import config, serialization, basestring
def from_config(cfg: config.NetConfig, dir_path: str = None):
if not isinstance(cfg, config.NetConfig):
cfg = config.NetConfig(**cfg)
def from_topology(topology, dir_path: str = None):
if topology is None:
return nx.Graph()
if isinstance(topology, nx.Graph):
return topology
if cfg.path:
path = cfg.path
# If it's a dict, assume it's a node-link graph
if isinstance(topology, dict):
try:
return nx.json_graph.node_link_graph(topology)
except Exception as ex:
raise ValueError("Unknown topology format")
# Otherwise, treat like a path
path = topology
if dir_path and not os.path.isabs(path):
path = os.path.join(dir_path, path)
extension = os.path.splitext(path)[1][1:]
@ -29,28 +38,19 @@ def from_config(cfg: config.NetConfig, dir_path: str = None):
raise AttributeError("Unknown format")
return method(path, **kwargs)
if cfg.params:
net_args = dict(cfg.params)
net_gen = net_args.pop("generator")
def from_params(generator, dir_path: str = None, **params):
if dir_path not in sys.path:
sys.path.append(dir_path)
method = serialization.deserializer(
net_gen,
generator,
known_modules=[
"networkx.generators",
],
)
return method(**net_args)
if isinstance(cfg.fixed, config.Topology):
cfg = cfg.fixed.dict()
if isinstance(cfg, str) or isinstance(cfg, dict):
return nx.json_graph.node_link_graph(cfg)
return nx.Graph()
return method(**params)
def find_unassigned(G, shuffle=False, random=random):

32
soil/parameters.py Normal file
View File

@ -0,0 +1,32 @@
from __future__ import annotations
from typing_extensions import Annotated
import annotated_types
from typing import *
from dataclasses import dataclass
class Parameter:
pass
def floatrange(
*,
gt: Optional[float] = None,
ge: Optional[float] = None,
lt: Optional[float] = None,
le: Optional[float] = None,
multiple_of: Optional[float] = None,
) -> type[float]:
return Annotated[
float,
annotated_types.Interval(gt=gt, ge=ge, lt=lt, le=le),
annotated_types.MultipleOf(multiple_of) if multiple_of is not None else None,
]
function = Annotated[Callable, Parameter]
Integer = Annotated[int, Parameter]
Float = Annotated[float, Parameter]
probability = floatrange(ge=0, le=1)

View File

@ -16,6 +16,7 @@ from typing import Any, Dict, Union, Optional, List
from networkx.readwrite import json_graph
from functools import partial
from contextlib import contextmanager
import pickle
from . import serialization, exporters, utils, basestring, agents
@ -23,6 +24,16 @@ from .environment import Environment
from .utils import logger, run_and_return_exceptions
from .config import Config, convert_old
_AVOID_RUNNING = False
_QUEUED = []
@contextmanager
def do_not_run():
global _AVOID_RUNNING
_AVOID_RUNNING = True
yield
_AVOID_RUNNING = False
# TODO: change documentation for simulation
@dataclass
@ -40,7 +51,7 @@ class Simulation:
name: str = "Unnamed simulation"
description: Optional[str] = ""
group: str = None
model_class: Union[str, type] = "soil.Environment"
model: Union[str, type] = "soil.Environment"
model_params: dict = field(default_factory=dict)
seed: str = field(default_factory=lambda: current_time())
dir_path: str = field(default_factory=lambda: os.getcwd())
@ -49,7 +60,6 @@ class Simulation:
interval: int = 1
num_trials: int = 1
num_processes: Optional[int] = 1
parallel: Optional[bool] = False
exporters: Optional[List[str]] = field(default_factory=lambda: [exporters.default])
model_reporters: Optional[Dict[str, Any]] = field(default_factory=dict)
agent_reporters: Optional[Dict[str, Any]] = field(default_factory=dict)
@ -90,6 +100,9 @@ class Simulation:
)
+ self.to_yaml()
)
if _AVOID_RUNNING:
_QUEUED.append((self, args, kwargs))
return list()
return list(self.run_gen(*args, **kwargs))
def run_gen(
@ -170,7 +183,7 @@ class Simulation:
tables = self.tables.copy()
tables.update(deserialize_reporters(params.pop("tables", {})))
env = serialization.deserialize(self.model_class)
env = serialization.deserialize(self.model)
return env(
id=f"{self.name}_trial_{trial_id}",
seed=f"{self.seed}_trial_{trial_id}",
@ -250,6 +263,14 @@ Model stats:
return yaml.dump(self.to_dict())
def iter_from_file(*files, **kwargs):
for f in files:
try:
yield from iter_from_py(f, **kwargs)
except ValueError as ex:
yield from iter_from_config(f, **kwargs)
def iter_from_config(*cfgs, **kwargs):
for config in cfgs:
configs = list(serialization.load_config(config))
@ -266,18 +287,38 @@ def from_config(conf_or_path):
raise AttributeError("Provide only one configuration")
return lst[0]
def iter_from_py(pyfile, module_name='custom_simulation'):
def iter_from_py(pyfile, module_name='custom_simulation', **kwargs):
"""Try to load every Simulation instance in a given Python file"""
import importlib
import inspect
added = False
with do_not_run():
spec = importlib.util.spec_from_file_location(module_name, pyfile)
folder = os.path.dirname(pyfile)
if folder not in sys.path:
added = True
sys.path.append(folder)
if not spec:
raise ValueError(f"{pyfile} does not seem to be a Python module")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
# import pdb;pdb.set_trace()
loaded = False
sims = []
for (_name, sim) in inspect.getmembers(module, lambda x: isinstance(x, Simulation)):
yield sim
loaded = True
sims.append(sim)
for (_name, sim) in inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, Simulation)):
loaded = True
sims.append(sim(**kwargs))
if not loaded:
raise AttributeError(f"No valid configurations found in {pyfile}")
del sys.modules[module_name]
if added:
sys.path.remove(folder)
yield from sims
def from_py(pyfile):
@ -285,7 +326,7 @@ def from_py(pyfile):
def run_from_config(*configs, **kwargs):
for sim in iter_from_config(*configs):
def run_from_file(*files, **kwargs):
for sim in iter_from_file(*files):
logger.info(f"Using config(s): {sim.name}")
sim.run_simulation(**kwargs)

View File

@ -1,6 +0,0 @@
from mesa.visualization.UserParam import UserSettableParameter
class UserSettableParameter(UserSettableParameter):
def __str__(self):
return self.value

View File

@ -106,7 +106,7 @@ class TestAgents(TestCase):
"""
# There are two agents, they try to send pings
# This is arguably a very contrived example. In practice, the or
# This is arguably a very contrived example.
# There should be a delay of one step between agent 0 and 1
# On the first step:
# Agent 0 sends a PING, but blocks before a PONG

View File

@ -1,4 +1,4 @@
from unittest import TestCase
from unittest import TestCase, skip
import os
import yaml
import copy
@ -23,6 +23,7 @@ def isequal(a, b):
assert a == b
@skip("new versions of soil do not rely on configuration files")
class TestConfig(TestCase):
def test_conversion(self):
expected = serialization.load_file(join(ROOT, "complete_converted.yml"))[0]
@ -59,16 +60,16 @@ class TestConfig(TestCase):
"""
cfg = {
"name": "CounterAgent",
"network_params": {"path": join(ROOT, "test.gexf")},
"model_params": {
"topology": join(ROOT, "test.gexf"),
"agent_class": "CounterModel",
},
# 'states': [{'times': 10}, {'times': 20}],
"max_time": 2,
"dry_run": True,
"num_trials": 1,
"environment_params": {},
}
conf = config.convert_old(cfg)
s = simulation.from_config(conf)
s = simulation.from_config(cfg)
env = s.get_env()
assert len(env.G.nodes) == 2

View File

@ -3,7 +3,7 @@ import os
from os.path import join
from glob import glob
from soil import simulation, config
from soil import simulation, config, do_not_run
ROOT = os.path.abspath(os.path.dirname(__file__))
EXAMPLES = join(ROOT, "..", "examples")
@ -12,6 +12,7 @@ FORCE_TESTS = os.environ.get("FORCE_TESTS", "")
class TestExamples(TestCase):
"""Empty class that will be populated with auto-discovery tests for every example"""
pass
@ -45,7 +46,7 @@ def add_example_tests():
continue
for sim in simulation.iter_from_config(path):
sim_paths.append((sim, path))
for path in glob(join(EXAMPLES, '**', '*.py')):
for path in glob(join(EXAMPLES, '**', '*_sim.py')):
for sim in simulation.iter_from_py(path):
sim_paths.append((sim, path))

View File

@ -6,6 +6,7 @@ import sqlite3
from unittest import TestCase
from soil import exporters
from soil import environment
from soil import simulation
from soil import agents
@ -38,15 +39,14 @@ class Exporters(TestCase):
def test_basic(self):
# We need to add at least one agent to make sure the scheduler
# ticks every step
class SimpleEnv(environment.Environment):
def init(self):
self.add_agent(agent_class=agents.BaseAgent)
num_trials = 5
max_time = 2
config = {
"name": "exporter_sim",
"model_params": {"agents": [{"agent_class": agents.BaseAgent}]},
"max_time": max_time,
"num_trials": num_trials,
}
s = simulation.from_config(config)
s = simulation.Simulation(num_trials=num_trials, max_time=max_time, name="exporter_sim", dry_run=True, model=SimpleEnv)
for env in s.run_simulation(exporters=[Dummy], dry_run=True):
assert len(env.agents) == 1
@ -64,12 +64,14 @@ class Exporters(TestCase):
n_trials = 5
config = {
"name": "exporter_sim",
"network_params": {"generator": "complete_graph", "n": 4},
"model_params": {
"network_generator": "complete_graph",
"network_params": {"n": 4},
"agent_class": "CounterModel",
},
"max_time": 2,
"num_trials": n_trials,
"dry_run": False,
"environment_params": {},
}
output = io.StringIO()
s = simulation.from_config(config)

View File

@ -29,8 +29,8 @@ class TestMain(TestCase):
"""A simulation with a base behaviour should do nothing"""
config = {
"model_params": {
"network_params": {"path": join(ROOT, "test.gexf")},
"agent_class": "BaseAgent",
"topology": join(ROOT, "test.gexf"),
"agent_class": "NetworkAgent",
}
}
s = simulation.from_config(config)
@ -62,27 +62,13 @@ class TestMain(TestCase):
"""
The initial states should be applied to the agent and the
agent should be able to update its state."""
config = {
"version": "2",
"name": "CounterAgent",
"dry_run": True,
"num_trials": 1,
"max_time": 2,
"model_params": {
"topology": {"path": join(ROOT, "test.gexf")},
"agents": {
"agent_class": "CounterModel",
"topology": True,
"fixed": [{"state": {"times": 10}}, {"state": {"times": 20}}],
},
},
}
s = simulation.from_config(config)
env = s.get_env()
assert isinstance(env.agents[0], agents.CounterModel)
assert env.agents[0].G == env.G
assert env.agents[0]["times"] == 10
env = Environment()
env.add_agent(agents.Ticker, times=10)
env.add_agent(agents.Ticker, times=20)
assert isinstance(env.agents[0], agents.Ticker)
assert env.agents[0]["times"] == 10
assert env.agents[1]["times"] == 20
env.step()
assert env.agents[0]["times"] == 11
assert env.agents[1]["times"] == 21
@ -90,18 +76,8 @@ class TestMain(TestCase):
def test_init_and_count_agents(self):
"""Agents should be properly initialized and counting should filter them properly"""
# TODO: separate this test into two or more test cases
config = {
"max_time": 10,
"model_params": {
"agents": [
{"agent_class": CustomAgent, "weight": 1, "topology": True},
{"agent_class": CustomAgent, "weight": 3, "topology": True},
],
"topology": {"path": join(ROOT, "test.gexf")},
},
}
s = simulation.from_config(config)
env = s.run_simulation(dry_run=True)[0]
env = Environment(topology=join(ROOT, "test.gexf"))
env.populate_network([CustomAgent.w(weight=1), CustomAgent.w(weight=3)])
assert env.agents[0].weight == 1
assert env.count_agents() == 2
assert env.count_agents(weight=1) == 1
@ -110,26 +86,28 @@ class TestMain(TestCase):
def test_torvalds_example(self):
"""A complete example from a documentation should work."""
config = serialization.load_file(join(EXAMPLES, "torvalds.yml"))[0]
config["model_params"]["network_params"]["path"] = join(
EXAMPLES, config["model_params"]["network_params"]["path"]
)
s = simulation.from_config(config)
owd = os.getcwd()
pyfile = join(EXAMPLES, "torvalds_sim.py")
try:
os.chdir(os.path.dirname(pyfile))
s = simulation.from_py(pyfile)
env = s.run_simulation(dry_run=True)[0]
for a in env.network_agents:
skill_level = a.state["skill_level"]
if a.id == "Torvalds":
skill_level = a["skill_level"]
if a.node_id == "Torvalds":
assert skill_level == "God"
assert a.state["total"] == 3
assert a.state["neighbors"] == 2
elif a.id == "balkian":
assert a["total"] == 3
assert a["neighbors"] == 2
elif a.node_id == "balkian":
assert skill_level == "developer"
assert a.state["total"] == 3
assert a.state["neighbors"] == 1
assert a["total"] == 3
assert a["neighbors"] == 1
else:
assert skill_level == "beginner"
assert a.state["total"] == 3
assert a.state["neighbors"] == 1
assert a["total"] == 3
assert a["neighbors"] == 1
finally:
os.chdir(owd)
def test_serialize_class(self):
ser, name = serialization.serialize(agents.BaseAgent, known_modules=[])
@ -166,11 +144,6 @@ class TestMain(TestCase):
assert ser == "BaseAgent"
pickle.dumps(ser)
def test_templates(self):
"""Loading a template should result in several configs"""
configs = serialization.load_file(join(EXAMPLES, "template.yml"))
assert len(configs) > 0
def test_until(self):
n_runs = 0
@ -183,7 +156,7 @@ class TestMain(TestCase):
n_trials = 50
max_time = 2
s = simulation.Simulation(
model_params={"agents": [{"agent_class": CheckRun}]},
model_params=dict(agents=dict(agent_classes=[CheckRun], k=1)),
num_trials=n_trials,
max_time=max_time,
)

View File

@ -19,13 +19,11 @@ class TestNetwork(TestCase):
Load a graph from file if the extension is known.
Raise an exception otherwise.
"""
config = {"network_params": {"path": join(ROOT, "test.gexf")}}
G = network.from_config(config["network_params"])
G = network.from_topology(join(ROOT, "test.gexf"))
assert G
assert len(G) == 2
with self.assertRaises(AttributeError):
config = {"network_params": {"path": join(ROOT, "unknown.extension")}}
G = network.from_config(config["network_params"])
G = network.from_topology(join(ROOT, "unknown.extension"))
print(G)
def test_generate_barabasi(self):
@ -33,12 +31,12 @@ class TestNetwork(TestCase):
If no path is given, a generator and network parameters
should be used to generate a network
"""
cfg = {"params": {"generator": "barabasi_albert_graph"}}
cfg = {"generator": "barabasi_albert_graph"}
with self.assertRaises(Exception):
G = network.from_config(cfg)
cfg["params"]["n"] = 100
cfg["params"]["m"] = 10
G = network.from_config(cfg)
G = network.from_params(**cfg)
cfg["n"] = 100
cfg["m"] = 10
G = network.from_params(**cfg)
assert len(G) == 100
def test_save_geometric(self):
@ -54,18 +52,8 @@ class TestNetwork(TestCase):
def test_networkenvironment_creation(self):
"""Networkenvironment should accept netconfig as parameters"""
model_params = {
"topology": {"path": join(ROOT, "test.gexf")},
"agents": {
"topology": True,
"distribution": [
{
"agent_class": CustomAgent,
}
],
},
}
env = environment.Environment(**model_params)
env = environment.Environment(topology=join(ROOT, "test.gexf"))
env.populate_network(CustomAgent)
assert env.G
env.step()
assert len(env.G) == 2
@ -76,18 +64,9 @@ class TestNetwork(TestCase):
def test_custom_agent_neighbors(self):
"""Allow for search of neighbors with a certain state_id"""
config = {
"model_params": {
"topology": {"path": join(ROOT, "test.gexf")},
"agents": {
"topology": True,
"distribution": [{"weight": 1, "agent_class": CustomAgent}],
},
},
"max_time": 10,
}
s = simulation.from_config(config)
env = s.run_simulation(dry_run=True)[0]
env = environment.Environment()
env.create_network(join(ROOT, "test.gexf"))
env.populate_network(CustomAgent)
assert env.agents[1].count_agents(state_id="normal") == 2
assert env.agents[1].count_agents(state_id="normal", limit_neighbors=True) == 1
assert env.agents[0].count_neighbors() == 1
@ -97,10 +76,8 @@ class TestNetwork(TestCase):
G = nx.Graph()
G.add_node(3)
G.add_edge(1, 2)
distro = agents.calculate_distribution(agent_class=agents.NetworkAgent)
aconfig = config.AgentConfig(distribution=distro, topology=True)
env = environment.Environment(name="Test", topology=G, agents=aconfig)
lst = list(env.network_agents)
env = environment.Environment(name="Test", topology=G)
env.populate_network(agents.NetworkAgent)
a2 = env.find_one(node_id=2)
a3 = env.find_one(node_id=3)

View File

@ -46,7 +46,8 @@ class TestMain(TestCase):
break
done.append(self.now)
env = environment.Environment(agents=[{"agent_class": CondAgent}])
env = environment.Environment()
env.add_agent(CondAgent)
while env.schedule.time < 11:
times.append(env.now)