1
0
mirror of https://github.com/gsi-upm/soil synced 2025-10-17 16:58:30 +00:00

Compare commits

...

20 Commits

Author SHA1 Message Date
J. Fernando Sánchez
092591fb97 Fix bug agent df with multiple steps per timestamp 2023-05-24 17:54:40 +02:00
J. Fernando Sánchez
263b4e0e33 Minor fixes
* Add option to set scheduler and environment start times
* Add `Agent.env` method to ease migration of earlier code
2023-05-22 20:29:25 +02:00
J. Fernando Sánchez
189836408f Add rescheduling for received 2023-05-19 16:19:50 +02:00
J. Fernando Sánchez
ee0c4517cb Decouple activation and schedulers 2023-05-16 09:05:23 +02:00
J. Fernando Sánchez
3041156f19 WIP 2023-05-12 14:09:00 +02:00
J. Fernando Sánchez
f49be3af68 1.0pre4 2023-05-03 12:14:49 +02:00
J. Fernando Sánchez
5e93399d58 Initial benchmarking 2023-05-03 12:07:55 +02:00
J. Fernando Sánchez
eca4cae298 Update tutorial and fix plotting bug 2023-04-24 19:23:04 +02:00
J. Fernando Sánchez
47a67f6665 Add jupyter to test-requirements 2023-04-24 19:04:53 +02:00
J. Fernando Sánchez
c13550cf83 Tweak ipynb testing 2023-04-24 18:57:07 +02:00
J. Fernando Sánchez
55bbc76b2a Improve tutorial
* Improve text
* Move to docs
* Autogenerate with sphinx
* Fix naming issue `environment.run` (double name)
* Add to tests
2023-04-24 18:47:52 +02:00
J. Fernando Sánchez
d13e4eb4b9 Plot env without agent reporters 2023-04-24 18:06:37 +02:00
J. Fernando Sánchez
93d23e4cab Add tutorial test to CI/CD 2023-04-24 18:05:07 +02:00
J. Fernando Sánchez
3802578ad5 Use context manager to add source files 2023-04-24 17:40:00 +02:00
J. Fernando Sánchez
4e296e0cf1 Merge branch 'mesa' 2023-04-21 15:19:21 +02:00
J. Fernando Sánchez
302075a65d Fix bug Py3.11 2023-04-21 14:27:41 +02:00
J. Fernando Sánchez
fba379c97c Update readme 2023-04-21 13:31:00 +02:00
J. Fernando Sánchez
50bca88362 Fix pre-release version of v1.0.0rc1 2023-04-20 18:07:42 +02:00
J. Fernando Sánchez
cc238d84ec Pre-release version of v1.0 2023-04-20 17:57:18 +02:00
J. Fernando Sánchez
bf481f0f88 v0.20.8 fix bugs 2023-03-23 14:49:09 +01:00
126 changed files with 5555 additions and 9575 deletions

View File

@@ -1,5 +1,7 @@
**/soil_output
.*
**/.*
**/__pycache__
__pycache__
*.pyc
**/backup

4
.gitignore vendored
View File

@@ -8,4 +8,6 @@ soil_output
docs/_build*
build/*
dist/*
prof
prof
backup
*.egg-info

View File

@@ -3,18 +3,40 @@ 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.30 UNRELEASED]
## [1.0 UNRELEASED]
Version 1.0 introduced multiple changes, especially on the `Simulation` class and anything related to how configuration is handled.
For an explanation of the general changes in version 1.0, please refer to the file `docs/notes_v1.0.rst`.
### 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 mesa simulations
* The `soil.exporters` module to export the results of datacollectors (model.datacollector) into files at the end of trials/simulations
* A modular set of classes for environments/models. Now the ability to configure the agents through an agent definition and a topology through a network configuration is split into two classes (`soil.agents.BaseEnvironment` for agents, `soil.agents.NetworkEnvironment` to add topology).
* FSM agents can now have generators as states. They work similar to normal states, with one caveat. Only `time` values can be yielded, not a state. This is because the state will not change, it will be resumed after the yield, at the appropriate time. The return value *can* be a state, or a `(state, time)` tuple, just like in normal states.
* Environments now have a class method to make them easier to use without a simulation`.run`. Notice that this is different from `run_model`, which is an instance method.
* Ability to run simulations using mesa models
* The `soil.exporters` module to export the results of datacollectors (`model.datacollector`) into files at the end of trials/simulations
* Agents can now have generators or async functions as their step or as states. They work similar to normal functions, with one caveat in the case of `FSM`: only time values (a float, int or None) can be awaited or yielded, not a state. This is because the state will not change, it will be resumed after the yield, at the appropriate time. To return to a different state, use the `delay` and `at` functions of the state.
* Simulations can now specify a `matrix` with possible values for every simulation parameter. The final parameters will be calculated based on the `parameters` used and a cartesian product (i.e., all possible combinations) of each parameter.
* Simple debugging capabilities in `soil.debugging`, with a custom `pdb.Debugger` subclass that exposes commands to list agents and their status and set breakpoints on states (for FSM agents). Try it with `soil --debug <simulation file>`
* The `agent.after` and `agent.at` methods, to avoid having to return a time manually.
### Changed
* Configuration schema is very simplified
* Configuration schema (`Simulation`) is very simplified. All simulations should be checked
* Agents that wish to
* Model / environment variables are expected (but not enforced) to be a single value. This is done to more closely align with mesa
* `Exporter.iteration_end` now takes two parameters: `env` (same as before) and `params` (specific parameters for this environment). We considered including a `parameters` attribute in the environment, but this would not be compatible with mesa.
* `num_trials` renamed to `iterations`
* General renaming of `trial` to `iteration`, to work better with `mesa`
* `model_parameters` renamed to `parameters` in simulation
* Simulation results for every iteration of a simulation with the same name are stored in a single `sqlite` database
### Removed
* The `time.When` and `time.Cond` classes are removed
* Any `tsih` and `History` integration in the main classes. To record the state of environments/agents, just use a datacollector. In some cases this may be slower or consume more memory than the previous system. However, few cases actually used the full potential of the history, and it came at the cost of unnecessary complexity and worse performance for the majority of cases.
## [0.20.8]
### Changed
* Tsih bumped to version 0.1.8
### Fixed
* Mentions to `id` in docs. It should be `state_id` now.
* Fixed bug: environment agents were not being added to the simulation
## [0.20.7]
### Changed

View File

@@ -4,10 +4,10 @@
Soil is an extensible and user-friendly Agent-based Social Simulator for Social Networks.
Learn how to run your own simulations with our [documentation](http://soilsim.readthedocs.io).
Follow our [tutorial](examples/tutorial/soil_tutorial.ipynb) to develop your own agent models.
Follow our [tutorial](docs/tutorial/soil_tutorial.ipynb) to develop your own agent models.
> **Warning**
> Mesa 0.30 introduced many fundamental changes. Check the [documention on how to update your simulations to work with newer versions](docs/notes_v0.30.rst)
> Soil 1.0 introduced many fundamental changes. Check the [documention on how to update your simulations to work with newer versions](docs/notes_v1.0.rst)
## Features
@@ -36,7 +36,6 @@ Follow our [tutorial](examples/tutorial/soil_tutorial.ipynb) to develop your own
* A command line interface (`soil`), to quickly run simulations with different parameters
* An integrated debugger (`soil --debug`) with custom functions to print agent states and break at specific states
## Mesa compatibility
SOIL has been redesigned to integrate well with [Mesa](https://github.com/projectmesa/mesa).

View File

@@ -0,0 +1,12 @@
command,mean,stddev,median,user,system,min,max,parameter_sim
python noop/mesa_batchrunner.py,1.3258325165599998,0.05822826666377271,1.31279976286,1.2978164199999997,0.25767558,1.2780627573599999,1.46763559736,mesa_batchrunner
python noop/mesa_simulation.py,1.3915081544599999,0.07311646048704976,1.37166811936,1.35267662,0.29222067999999995,1.32746067836,1.58495303336,mesa_simulation
python noop/soil_step.py,1.9859962588599998,0.12143759641749913,1.93586195486,2.0000750199999997,0.54126188,1.9061700903599998,2.2532835533599997,soil_step
python noop/soil_step_pqueue.py,2.1347049971600005,0.01336179424666973,2.13492341986,2.1368160200000004,0.56862948,2.11810132936,2.16042739636,soil_step_pqueue
python noop/soil_gens.py,2.1284937893599998,0.03030587681163665,2.13585231586,2.14158812,0.54900038,2.0768625143599997,2.19043625236,soil_gens
python noop/soil_gens_pqueue.py,2.3469003942599995,0.019461346004472344,2.3486906343599996,2.36505852,0.54629858,2.31766326036,2.37998102136,soil_gens_pqueue
python noop/soil_async.py,2.85755484126,0.0314955571121844,2.84774029536,2.86388112,0.55261338,2.81428668936,2.90567961636,soil_async
python noop/soil_async_pqueue.py,3.1999731134600005,0.04432336803797717,3.20255954186,3.2162337199999995,0.5501872800000001,3.1406816913599997,3.26137401936,soil_async_pqueue
python noop/soilent_step.py,1.30038977816,0.017973958957989845,1.30187804986,1.3231730199999998,0.5452653799999999,1.27058263436,1.31902240836,soilent_step
python noop/soilent_step_pqueue.py,1.4708435788599998,0.027193290392962755,1.4707784423599999,1.4900387199999998,0.54749428,1.43498127536,1.53065598436,soilent_step_pqueue
python noop/soilent_gens.py,1.6338810973599998,0.05752539125688073,1.63513330036,1.65216122,0.51846678,1.54135944036,1.7038832853599999,soilent_gens
1 command mean stddev median user system min max parameter_sim
2 python noop/mesa_batchrunner.py 1.3258325165599998 0.05822826666377271 1.31279976286 1.2978164199999997 0.25767558 1.2780627573599999 1.46763559736 mesa_batchrunner
3 python noop/mesa_simulation.py 1.3915081544599999 0.07311646048704976 1.37166811936 1.35267662 0.29222067999999995 1.32746067836 1.58495303336 mesa_simulation
4 python noop/soil_step.py 1.9859962588599998 0.12143759641749913 1.93586195486 2.0000750199999997 0.54126188 1.9061700903599998 2.2532835533599997 soil_step
5 python noop/soil_step_pqueue.py 2.1347049971600005 0.01336179424666973 2.13492341986 2.1368160200000004 0.56862948 2.11810132936 2.16042739636 soil_step_pqueue
6 python noop/soil_gens.py 2.1284937893599998 0.03030587681163665 2.13585231586 2.14158812 0.54900038 2.0768625143599997 2.19043625236 soil_gens
7 python noop/soil_gens_pqueue.py 2.3469003942599995 0.019461346004472344 2.3486906343599996 2.36505852 0.54629858 2.31766326036 2.37998102136 soil_gens_pqueue
8 python noop/soil_async.py 2.85755484126 0.0314955571121844 2.84774029536 2.86388112 0.55261338 2.81428668936 2.90567961636 soil_async
9 python noop/soil_async_pqueue.py 3.1999731134600005 0.04432336803797717 3.20255954186 3.2162337199999995 0.5501872800000001 3.1406816913599997 3.26137401936 soil_async_pqueue
10 python noop/soilent_step.py 1.30038977816 0.017973958957989845 1.30187804986 1.3231730199999998 0.5452653799999999 1.27058263436 1.31902240836 soilent_step
11 python noop/soilent_step_pqueue.py 1.4708435788599998 0.027193290392962755 1.4707784423599999 1.4900387199999998 0.54749428 1.43498127536 1.53065598436 soilent_step_pqueue
12 python noop/soilent_gens.py 1.6338810973599998 0.05752539125688073 1.63513330036 1.65216122 0.51846678 1.54135944036 1.7038832853599999 soilent_gens

11
benchmarks/noop-bench.csv Normal file
View File

@@ -0,0 +1,11 @@
command,mean,stddev,median,user,system,min,max,parameter_sim
python noop/mesa1_batchrunner.py,1.2559917394000002,0.012031173494887278,1.2572688413000002,1.2168630799999998,0.31825289999999995,1.2346063853,1.2735512493,mesa1_batchrunner
python noop/mesa1_simulation.py,1.3024417227,0.022498874113931668,1.2994157323,1.2595484799999999,0.3087897,1.2697029703,1.3350640403,mesa1_simulation
python noop/soil1.py,1.8789492443,0.18023367899835044,1.8186795393000001,1.86076288,0.5309521,1.7326687413000001,2.2928370642999996,soil1
python noop/soil1_pqueue.py,1.9841675890000001,0.01735524088843906,1.9884363323,2.01830338,0.5787977999999999,1.9592171483,2.0076169282999996,soil1_pqueue
python noop/soil2.py,2.0135188921999996,0.02869307129649681,2.0184709453,2.03951308,0.5885591,1.9680417823,2.0567112592999997,soil2
python noop/soil2_pqueue.py,2.2367320454999997,0.024339667344486046,2.2357249777999995,2.2515216799999997,0.5978869,2.1957917303,2.2688685033,soil2_pqueue
python noop/soilent1.py,1.1309301329,0.015133005948737871,1.1276461497999999,1.14056688,0.6027519,1.1135821423,1.1625753893,soilent1
python noop/soilent1_pqueue.py,1.3097537665000003,0.018821977712258842,1.3073709358,1.3270259799999997,0.6000067999999998,1.2874580013,1.3381646823,soilent1_pqueue
python noop/soilent2.py,1.5055360476,0.05166674417574119,1.4883118568,1.5121205799999997,0.5817363999999999,1.4490918363,1.6005909333000001,soilent2
python noop/soilent2_pqueue.py,1.6622598218,0.031130739036296016,1.6588702603,1.6862567799999997,0.5854159,1.6289724583,1.7330545383,soilent2_pqueue
1 command mean stddev median user system min max parameter_sim
2 python noop/mesa1_batchrunner.py 1.2559917394000002 0.012031173494887278 1.2572688413000002 1.2168630799999998 0.31825289999999995 1.2346063853 1.2735512493 mesa1_batchrunner
3 python noop/mesa1_simulation.py 1.3024417227 0.022498874113931668 1.2994157323 1.2595484799999999 0.3087897 1.2697029703 1.3350640403 mesa1_simulation
4 python noop/soil1.py 1.8789492443 0.18023367899835044 1.8186795393000001 1.86076288 0.5309521 1.7326687413000001 2.2928370642999996 soil1
5 python noop/soil1_pqueue.py 1.9841675890000001 0.01735524088843906 1.9884363323 2.01830338 0.5787977999999999 1.9592171483 2.0076169282999996 soil1_pqueue
6 python noop/soil2.py 2.0135188921999996 0.02869307129649681 2.0184709453 2.03951308 0.5885591 1.9680417823 2.0567112592999997 soil2
7 python noop/soil2_pqueue.py 2.2367320454999997 0.024339667344486046 2.2357249777999995 2.2515216799999997 0.5978869 2.1957917303 2.2688685033 soil2_pqueue
8 python noop/soilent1.py 1.1309301329 0.015133005948737871 1.1276461497999999 1.14056688 0.6027519 1.1135821423 1.1625753893 soilent1
9 python noop/soilent1_pqueue.py 1.3097537665000003 0.018821977712258842 1.3073709358 1.3270259799999997 0.6000067999999998 1.2874580013 1.3381646823 soilent1_pqueue
10 python noop/soilent2.py 1.5055360476 0.05166674417574119 1.4883118568 1.5121205799999997 0.5817363999999999 1.4490918363 1.6005909333000001 soilent2
11 python noop/soilent2_pqueue.py 1.6622598218 0.031130739036296016 1.6588702603 1.6862567799999997 0.5854159 1.6289724583 1.7330545383 soilent2_pqueue

View File

@@ -0,0 +1,26 @@
import os
NUM_AGENTS = int(os.environ.get('NUM_AGENTS', 100))
NUM_ITERS = int(os.environ.get('NUM_ITERS', 10))
MAX_STEPS = int(os.environ.get('MAX_STEPS', 1000))
def run_sim(model, **kwargs):
from soil import Simulation
opts = dict(model=model,
dump=False,
num_processes=1,
parameters={'num_agents': NUM_AGENTS},
seed="",
max_steps=MAX_STEPS,
iterations=NUM_ITERS)
opts.update(kwargs)
res = Simulation(**opts).run()
total = sum(a.num_calls for e in res for a in e.schedule.agents)
expected = NUM_AGENTS * NUM_ITERS * MAX_STEPS
print(total)
print(expected)
assert total == expected
return res

View File

@@ -0,0 +1,43 @@
from mesa import batch_run, DataCollector, Agent, Model
from mesa.time import RandomActivation
class NoopAgent(Agent):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.num_calls = 0
def step(self):
self.num_calls += 1
class NoopModel(Model):
def __init__(self, N):
super().__init__()
self.schedule = RandomActivation(self)
for i in range(N):
self.schedule.add(NoopAgent(self.next_id(), self))
self.datacollector = DataCollector(model_reporters={"num_agents": lambda m: m.schedule.get_agent_count(),
"time": lambda m: m.schedule.time},
agent_reporters={"num_calls": "num_calls"})
self.datacollector.collect(self)
def step(self):
self.schedule.step()
self.datacollector.collect(self)
if __name__ == "__main__":
from _config import *
res = batch_run(model_cls=NoopModel,
max_steps=MAX_STEPS,
iterations=NUM_ITERS,
number_processes=1,
parameters={'N': NUM_AGENTS})
total = sum(s["num_calls"] for s in res)
total_agents = sum(s["num_agents"] for s in res)
assert len(res) == NUM_AGENTS * NUM_ITERS
assert total == NUM_AGENTS * NUM_ITERS * MAX_STEPS
assert total_agents == NUM_AGENTS * NUM_AGENTS * NUM_ITERS

View File

@@ -0,0 +1,37 @@
from mesa import batch_run, DataCollector, Agent, Model
from mesa.time import RandomActivation
from soil import Simulation
from _config import *
class NoopAgent(Agent):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.num_calls = 0
def step(self):
self.num_calls += 1
class NoopModel(Model):
def __init__(self, num_agents, *args, **kwargs):
super().__init__()
self.schedule = RandomActivation(self)
for i in range(num_agents):
self.schedule.add(NoopAgent(self.next_id(), self))
self.datacollector = DataCollector(model_reporters={"num_agents": lambda m: m.schedule.get_agent_count(),
"time": lambda m: m.schedule.time},
agent_reporters={"num_calls": "num_calls"})
self.datacollector.collect(self)
def step(self):
self.schedule.step()
self.datacollector.collect(self)
def run():
run_sim(model=NoopModel)
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,3 @@
command,mean,stddev,median,user,system,min,max,parameter_sim
python mesa1_batchrunner.py,1.2932078178200002,0.05649377020829272,1.2705532802200001,1.25902256,0.27242284,1.22210926572,1.40867459172,mesa1_batchrunner
python mesa1_simulation.py,1.81112963812,0.015491072368938567,1.81342524572,1.8594407599999996,0.8005329399999999,1.78538603972,1.84176361172,mesa1_simulation
1 command mean stddev median user system min max parameter_sim
2 python mesa1_batchrunner.py 1.2932078178200002 0.05649377020829272 1.2705532802200001 1.25902256 0.27242284 1.22210926572 1.40867459172 mesa1_batchrunner
3 python mesa1_simulation.py 1.81112963812 0.015491072368938567 1.81342524572 1.8594407599999996 0.8005329399999999 1.78538603972 1.84176361172 mesa1_simulation

View File

@@ -0,0 +1,24 @@
from soil import BaseAgent, Environment, Simulation
class NoopAgent(BaseAgent):
num_calls = 0
async def step(self):
while True:
self.num_calls += 1
await self.delay()
class NoopEnvironment(Environment):
num_agents = 100
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
run_sim(model=NoopEnvironment)

View File

@@ -0,0 +1,25 @@
from soil import BaseAgent, Environment, Simulation, PQueueActivation
class NoopAgent(BaseAgent):
num_calls = 0
async def step(self):
while True:
self.num_calls += 1
await self.delay()
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = PQueueActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
run_sim(model=NoopEnvironment)

View File

@@ -0,0 +1,24 @@
from soil import BaseAgent, Environment, Simulation
class NoopAgent(BaseAgent):
num_calls = 0
def step(self):
while True:
self.num_calls += 1
yield self.delay()
class NoopEnvironment(Environment):
num_agents = 100
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
run_sim(model=NoopEnvironment)

View File

@@ -0,0 +1,25 @@
from soil import BaseAgent, Environment, Simulation, PQueueActivation
class NoopAgent(BaseAgent):
num_calls = 0
def step(self):
while True:
self.num_calls += 1
yield self.delay()
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = PQueueActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
run_sim(model=NoopEnvironment)

View File

@@ -0,0 +1,21 @@
from soil import Agent, Environment, Simulation, state
class NoopAgent(Agent):
num_calls = 0
@state(default=True)
def unique(self):
self.num_calls += 1
class NoopEnvironment(Environment):
num_agents = 100
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
from _config import *
run_sim(model=NoopEnvironment)

View File

@@ -0,0 +1,20 @@
from soil import Agent, Environment, Simulation
class NoopAgent(Agent):
num_calls = 0
def step(self):
self.num_calls += 1
class NoopEnvironment(Environment):
num_agents = 100
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
from _config import *
run_sim(model=NoopEnvironment)

View File

@@ -0,0 +1,22 @@
from soil import BaseAgent, Environment, Simulation, PQueueActivation
class NoopAgent(BaseAgent):
num_calls = 0
def step(self):
self.num_calls += 1
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = PQueueActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
run_sim(model=NoopEnvironment)

View File

@@ -0,0 +1,29 @@
from soil import Agent, Environment, Simulation
from soil.time import SoilentActivation
class NoopAgent(Agent):
num_calls = 0
async def step(self):
while True:
self.num_calls += 1
# yield self.delay(1)
await self.delay()
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = SoilentActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
res = run_sim(model=NoopEnvironment)
for r in res:
assert isinstance(r.schedule, SoilentActivation)

View File

@@ -0,0 +1,27 @@
from soil import Agent, Environment
from soil.time import SoilentPQueueActivation
class NoopAgent(Agent):
num_calls = 0
async def step(self):
while True:
self.num_calls += 1
await self.delay()
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = SoilentPQueueActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
res = run_sim(model=NoopEnvironment)
for r in res:
assert isinstance(r.schedule, SoilentPQueueActivation)

View File

@@ -0,0 +1,28 @@
from soil import Agent, Environment, Simulation
from soil.time import SoilentActivation
class NoopAgent(Agent):
num_calls = 0
def step(self):
while True:
self.num_calls += 1
# yield self.delay(1)
yield self.delay()
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = SoilentActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
res = run_sim(model=NoopEnvironment)
for r in res:
assert isinstance(r.schedule, SoilentActivation)

View File

@@ -0,0 +1,28 @@
from soil import Agent, Environment
from soil.time import SoilentPQueueActivation
class NoopAgent(Agent):
num_calls = 0
def step(self):
while True:
self.num_calls += 1
# yield self.delay(1)
yield
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = SoilentPQueueActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
res = run_sim(model=NoopEnvironment)
for r in res:
assert isinstance(r.schedule, SoilentPQueueActivation)

View File

@@ -0,0 +1,30 @@
from soil import Agent, Environment, Simulation, state
from soil.time import SoilentActivation
class NoopAgent(Agent):
num_calls = 0
@state(default=True)
async def unique(self):
while True:
self.num_calls += 1
# yield self.delay(1)
await self.delay()
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = SoilentActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
res = run_sim(model=NoopEnvironment)
for r in res:
assert isinstance(r.schedule, SoilentActivation)

View File

@@ -0,0 +1,24 @@
from soil import BaseAgent, Environment, Simulation
from soil.time import SoilentActivation
class NoopAgent(BaseAgent):
num_calls = 0
def step(self):
self.num_calls += 1
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = SoilentActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
res = run_sim(model=NoopEnvironment)
for r in res:
assert isinstance(r.schedule, SoilentActivation)

View File

@@ -0,0 +1,24 @@
from soil import BaseAgent, Environment, Simulation
from soil.time import SoilentPQueueActivation
class NoopAgent(BaseAgent):
num_calls = 0
def step(self):
self.num_calls += 1
class NoopEnvironment(Environment):
num_agents = 100
schedule_class = SoilentPQueueActivation
def init(self):
self.add_agents(NoopAgent, k=self.num_agents)
self.add_agent_reporter("num_calls")
if __name__ == "__main__":
from _config import *
res = run_sim(model=NoopEnvironment)
for r in res:
assert isinstance(r.schedule, SoilentPqueueActivation)

19
benchmarks/run.py Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/env python
import sys
import os
import subprocess
import argparse
parser = argparse.ArgumentParser(
prog='Profiler for soil')
parser.add_argument('--suffix', default=None)
parser.add_argument('files', nargs="+")
args = parser.parse_args()
for fname in args.files:
suffix = ("_" + args.suffix) if args.suffix else ""
simname = f"{fname.replace('/', '-')}{suffix}"
profpath = os.path.join("profs", simname + ".prof")
print(f"Running {fname} and saving profile to {profpath}")
subprocess.call(["python", "-m", "cProfile", "-o", profpath, fname])

View File

@@ -0,0 +1,4 @@
command,mean,stddev,median,user,system,min,max,parameter_sim
python virusonnetwork/mesa_basic.py,3.8381473157,0.0518143371442526,3.8475315791,3.873109219999999,0.55102658,3.7523016936,3.9095182436,mesa_basic.py
python virusonnetwork/soil_step.py,3.2167258977000004,0.02337131987357665,3.2257620261,3.28374132,0.51343958,3.1792271306,3.2511521286000002,soil_step.py
python virusonnetwork/soil_states.py,3.4908183217,0.03726734070349347,3.4912775086,3.5684004200000006,0.50416068,3.4272087936,3.5529207346000002,soil_states.py
1 command mean stddev median user system min max parameter_sim
2 python virusonnetwork/mesa_basic.py 3.8381473157 0.0518143371442526 3.8475315791 3.873109219999999 0.55102658 3.7523016936 3.9095182436 mesa_basic.py
3 python virusonnetwork/soil_step.py 3.2167258977000004 0.02337131987357665 3.2257620261 3.28374132 0.51343958 3.1792271306 3.2511521286000002 soil_step.py
4 python virusonnetwork/soil_states.py 3.4908183217 0.03726734070349347 3.4912775086 3.5684004200000006 0.50416068 3.4272087936 3.5529207346000002 soil_states.py

View File

@@ -0,0 +1,38 @@
import os
from soil import simulation
NUM_AGENTS = int(os.environ.get('NUM_AGENTS', 100))
NUM_ITERS = int(os.environ.get('NUM_ITERS', 10))
MAX_STEPS = int(os.environ.get('MAX_STEPS', 500))
def run_sim(model, **kwargs):
from soil import Simulation
opts = dict(model=model,
dump=False,
num_processes=1,
parameters={'num_nodes': NUM_AGENTS,
"avg_node_degree": 3,
"initial_outbreak_size": 5,
"virus_spread_chance": 0.25,
"virus_check_frequency": 0.25,
"recovery_chance": 0.3,
"gain_resistance_chance": 0.1,
},
max_steps=MAX_STEPS,
iterations=NUM_ITERS)
opts.update(kwargs)
its = Simulation(**opts).run()
assert len(its) == NUM_ITERS
if not simulation._AVOID_RUNNING:
ratios = list(it.resistant_susceptible_ratio for it in its)
print("Max - Avg - Min ratio:", max(ratios), sum(ratios)/len(ratios), min(ratios))
infected = list(it.number_infected for it in its)
print("Max - Avg - Min infected:", max(infected), sum(infected)/len(infected), min(infected))
assert all((it.schedule.steps == MAX_STEPS or it.number_infected == 0) for it in its)
assert all(sum([it.number_susceptible,
it.number_infected,
it.number_resistant]) == NUM_AGENTS for it in its)
return its

View File

@@ -0,0 +1,180 @@
# Verbatim copy from mesa
# https://github.com/projectmesa/mesa/blob/976ddfc8a1e5feaaf8007a7abaa9abc7093881a0/examples/virus_on_network/virus_on_network/model.py
import math
from enum import Enum
import networkx as nx
import mesa
class State(Enum):
SUSCEPTIBLE = 0
INFECTED = 1
RESISTANT = 2
def number_state(model, state):
return sum(1 for a in model.grid.get_all_cell_contents() if a.state is state)
def number_infected(model):
return number_state(model, State.INFECTED)
def number_susceptible(model):
return number_state(model, State.SUSCEPTIBLE)
def number_resistant(model):
return number_state(model, State.RESISTANT)
class VirusOnNetwork(mesa.Model):
"""A virus model with some number of agents"""
def __init__(
self,
*args,
num_nodes=10,
avg_node_degree=3,
initial_outbreak_size=1,
virus_spread_chance=0.4,
virus_check_frequency=0.4,
recovery_chance=0.3,
gain_resistance_chance=0.5,
**kwargs,
):
self.num_nodes = num_nodes
prob = avg_node_degree / self.num_nodes
self.G = nx.erdos_renyi_graph(n=self.num_nodes, p=prob)
self.grid = mesa.space.NetworkGrid(self.G)
self.schedule = mesa.time.RandomActivation(self)
self.initial_outbreak_size = (
initial_outbreak_size if initial_outbreak_size <= num_nodes else num_nodes
)
self.virus_spread_chance = virus_spread_chance
self.virus_check_frequency = virus_check_frequency
self.recovery_chance = recovery_chance
self.gain_resistance_chance = gain_resistance_chance
self.datacollector = mesa.DataCollector(
{
"Ratio": "resistant_susceptible_ratio",
"Infected": number_infected,
"Susceptible": number_susceptible,
"Resistant": number_resistant,
}
)
# Create agents
for i, node in enumerate(self.G.nodes()):
a = VirusAgent(
i,
self,
State.SUSCEPTIBLE,
self.virus_spread_chance,
self.virus_check_frequency,
self.recovery_chance,
self.gain_resistance_chance,
)
self.schedule.add(a)
# Add the agent to the node
self.grid.place_agent(a, node)
# Infect some nodes
infected_nodes = self.random.sample(list(self.G), self.initial_outbreak_size)
for a in self.grid.get_cell_list_contents(infected_nodes):
a.state = State.INFECTED
self.running = True
self.datacollector.collect(self)
@property
def number_susceptible(self):
return number_susceptible(self)
@property
def number_resistant(self):
return number_resistant(self)
@property
def number_infected(self):
return number_infected(self)
@property
def resistant_susceptible_ratio(self):
try:
return number_state(self, State.RESISTANT) / number_state(
self, State.SUSCEPTIBLE
)
except ZeroDivisionError:
return math.inf
def step(self):
self.schedule.step()
# collect data
self.datacollector.collect(self)
def run_model(self, n):
for i in range(n):
self.step()
class VirusAgent(mesa.Agent):
def __init__(
self,
unique_id,
model,
initial_state,
virus_spread_chance,
virus_check_frequency,
recovery_chance,
gain_resistance_chance,
):
super().__init__(unique_id, model)
self.state = initial_state
self.virus_spread_chance = virus_spread_chance
self.virus_check_frequency = virus_check_frequency
self.recovery_chance = recovery_chance
self.gain_resistance_chance = gain_resistance_chance
def try_to_infect_neighbors(self):
neighbors_nodes = self.model.grid.get_neighbors(self.pos, include_center=False)
susceptible_neighbors = [
agent
for agent in self.model.grid.get_cell_list_contents(neighbors_nodes)
if agent.state is State.SUSCEPTIBLE
]
for a in susceptible_neighbors:
if self.random.random() < self.virus_spread_chance:
a.state = State.INFECTED
def try_gain_resistance(self):
if self.random.random() < self.gain_resistance_chance:
self.state = State.RESISTANT
def try_remove_infection(self):
# Try to remove
if self.random.random() < self.recovery_chance:
# Success
self.state = State.SUSCEPTIBLE
self.try_gain_resistance()
else:
# Failed
self.state = State.INFECTED
def try_check_situation(self):
if self.random.random() < self.virus_check_frequency:
# Checking...
if self.state is State.INFECTED:
self.try_remove_infection()
def step(self):
if self.state is State.INFECTED:
self.try_to_infect_neighbors()
self.try_check_situation()
from _config import run_sim
run_sim(model=VirusOnNetwork)

View File

@@ -0,0 +1,91 @@
# Verbatim copy from mesa
# https://github.com/projectmesa/mesa/blob/976ddfc8a1e5feaaf8007a7abaa9abc7093881a0/examples/virus_on_network/virus_on_network/model.py
import math
from enum import Enum
import networkx as nx
from soil import *
class VirusOnNetwork(Environment):
"""A virus model with some number of agents"""
num_nodes = 10
avg_node_degree = 3
initial_outbreak_size = 1
virus_spread_chance = 0.4
virus_check_frequency = 0.4
recovery_chance = 0
gain_resistance_chance = 0
def init(self):
prob = self.avg_node_degree / self.num_nodes
# Use internal seed with the networkx generator
self.create_network(generator=nx.erdos_renyi_graph, n=self.num_nodes, p=prob)
self.initial_outbreak_size = min(self.initial_outbreak_size, self.num_nodes)
self.populate_network(VirusAgent)
# Infect some nodes
infected_nodes = self.random.sample(list(self.G), self.initial_outbreak_size)
for a in self.agents(node_id=infected_nodes):
a.set_state(VirusAgent.infected)
assert self.number_infected == self.initial_outbreak_size
def step(self):
super().step()
@report
@property
def resistant_susceptible_ratio(self):
try:
return self.number_resistant / self.number_susceptible
except ZeroDivisionError:
return math.inf
@report
@property
def number_infected(self):
return self.count_agents(state_id=VirusAgent.infected.id)
@report
@property
def number_susceptible(self):
return self.count_agents(state_id=VirusAgent.susceptible.id)
@report
@property
def number_resistant(self):
return self.count_agents(state_id=VirusAgent.resistant.id)
class VirusAgent(Agent):
virus_spread_chance = None # Inherit from model
virus_check_frequency = None # Inherit from model
recovery_chance = None # Inherit from model
gain_resistance_chance = None # Inherit from model
@state(default=True)
async def susceptible(self):
await self.received()
return self.infected
@state
def infected(self):
susceptible_neighbors = self.get_neighbors(state_id=self.susceptible.id)
for a in susceptible_neighbors:
if self.prob(self.virus_spread_chance):
a.tell(True, sender=self)
if self.prob(self.virus_check_frequency):
if self.prob(self.recovery_chance):
if self.prob(self.gain_resistance_chance):
return self.resistant
else:
return self.susceptible
@state
def resistant(self):
return self.at(INFINITY)
from _config import run_sim
run_sim(model=VirusOnNetwork)

View File

@@ -0,0 +1,104 @@
# Verbatim copy from mesa
# https://github.com/projectmesa/mesa/blob/976ddfc8a1e5feaaf8007a7abaa9abc7093881a0/examples/virus_on_network/virus_on_network/model.py
import math
from enum import Enum
import networkx as nx
from soil import *
class State(Enum):
SUSCEPTIBLE = 0
INFECTED = 1
RESISTANT = 2
class VirusOnNetwork(Environment):
"""A virus model with some number of agents"""
num_nodes = 10
avg_node_degree = 3
initial_outbreak_size = 1
virus_spread_chance = 0.4
virus_check_frequency = 0.4
recovery_chance = 0
gain_resistance_chance = 0
def init(self):
prob = self.avg_node_degree / self.num_nodes
# Use internal seed with the networkx generator
self.create_network(generator=nx.erdos_renyi_graph, n=self.num_nodes, p=prob)
self.initial_outbreak_size = min(self.initial_outbreak_size, self.num_nodes)
self.populate_network(VirusAgent)
# Infect some nodes
infected_nodes = self.random.sample(list(self.G), self.initial_outbreak_size)
for a in self.agents(node_id=infected_nodes):
a.status = State.INFECTED
assert self.number_infected == self.initial_outbreak_size
@report
@property
def resistant_susceptible_ratio(self):
try:
return self.number_resistant / self.number_susceptible
except ZeroDivisionError:
return math.inf
@report
@property
def number_infected(self):
return self.count_agents(status=State.INFECTED)
@report
@property
def number_susceptible(self):
return self.count_agents(status=State.SUSCEPTIBLE)
@report
@property
def number_resistant(self):
return self.count_agents(status=State.RESISTANT)
class VirusAgent(Agent):
status = State.SUSCEPTIBLE
virus_spread_chance = None # Inherit from model
virus_check_frequency = None # Inherit from model
recovery_chance = None # Inherit from model
gain_resistance_chance = None # Inherit from model
def try_to_infect_neighbors(self):
susceptible_neighbors = self.get_neighbors(status=State.SUSCEPTIBLE)
for a in susceptible_neighbors:
if self.prob(self.virus_spread_chance):
a.status = State.INFECTED
def try_gain_resistance(self):
if self.prob(self.gain_resistance_chance):
self.status = State.RESISTANT
return self.at(INFINITY)
def try_remove_infection(self):
# Try to remove
if self.prob(self.recovery_chance):
# Success
self.status = State.SUSCEPTIBLE
return self.try_gain_resistance()
def try_check_situation(self):
if self.prob(self.virus_check_frequency):
# Checking...
if self.status is State.INFECTED:
return self.try_remove_infection()
def step(self):
if self.status is State.INFECTED:
self.try_to_infect_neighbors()
return self.try_check_situation()
from _config import run_sim
run_sim(model=VirusOnNetwork)

View File

@@ -31,7 +31,10 @@
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ['IPython.sphinxext.ipython_console_highlighting']
extensions = [
"IPython.sphinxext.ipython_console_highlighting",
"nbsphinx",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -64,7 +67,7 @@ release = '0.1'
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@@ -152,6 +155,3 @@ texinfo_documents = [
author, 'Soil', 'One line description of project.',
'Miscellaneous'),
]

View File

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

View File

@@ -1,7 +1,21 @@
Welcome to Soil's documentation!
================================
Soil is an Agent-based Social Simulator in Python focused on Social Networks.
Soil is an opinionated Agent-based Social Simulator in Python focused on Social Networks.
To get started developing your own simulations and agent behaviors, check out our :doc:`Tutorial <tutorial/soil_tutorial>` and the `examples on GitHub <https://github.com/gsi-upm/soil/tree/master/examples>`.
Soil can be installed through pip (see more details in the :doc:`installation` page):.
.. image:: soil.png
:width: 80%
:align: center
.. code:: bash
pip install soil
If you use Soil in your research, do not forget to cite this paper:
@@ -33,9 +47,9 @@ If you use Soil in your research, do not forget to cite this paper:
:caption: Learn more about soil:
installation
quickstart
configuration
Tutorial <soil_tutorial>
Tutorial <tutorial/soil_tutorial>
notes_v1.0
soil-vs
..

View File

@@ -1,7 +1,10 @@
Installation
------------
The easiest way to install Soil is through pip, with Python >= 3.4:
Through pip
===========
The easiest way to install Soil is through pip, with Python >= 3.8:
.. code:: bash
@@ -25,4 +28,38 @@ Or, if you're using using soil programmatically:
import soil
print(soil.__version__)
The latest version can be installed through `GitHub <https://github.com/gsi-upm/soil>`_ or `GitLab <https://lab.gsi.upm.es/soil/soil.git>`_.
Web UI
======
Soil also includes a web server that allows you to upload your simulations, change parameters, and visualize the results, including a timeline of the network.
To make it work, you have to install soil like this:
.. code::
pip install soil[web]
Once installed, the soil web UI can be run in two ways:
.. code::
soil-web
# OR
python -m soil.web
Development
===========
The latest version can be downloaded from `GitHub <https://github.com/gsi-upm/soil>`_ and installed manually:
.. code:: bash
git clone https://github.com/gsi-upm/soil
cd soil
python -m venv .venv
source .venv/bin/activate
pip install --editable .

View File

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

View File

@@ -1,7 +1,10 @@
What are the main changes between version 0.3 and 0.2?
######################################################
Upgrading to Soil 1.0
---------------------
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.
What are the main changes in version 1.0?
#########################################
Version 1.0 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,93 +0,0 @@
Quickstart
----------
This section shows how to run your first simulation with Soil.
For installation instructions, see :doc:`installation`.
There are mainly two parts in a simulation: agent classes and simulation configuration.
An agent class defines how the agent will behave throughout the simulation.
The configuration includes things such as number of agents to use and their type, network topology to use, etc.
.. image:: soil.png
:width: 80%
:align: center
Soil includes several agent classes in the ``soil.agents`` module, and we will use them in this quickstart.
If you are interested in developing your own agents classes, see :doc:`soil_tutorial`.
Configuration
=============
To get you started, we will use this configuration (:download:`download the file <quickstart.yml>` directly):
.. literalinclude:: quickstart.yml
:language: yaml
The agent type used, SISa, is a very simple model.
It only has three states (neutral, content and discontent),
Its parameters are the probabilities to change from one state to another, either spontaneously or because of contagion from neighboring agents.
Running the simulation
======================
To see the simulation in action, simply point soil to the configuration, and tell it to store the graph and the history of agent states and environment parameters at every point.
.. code::
soil --graph --csv quickstart.yml [13:35:29]
INFO:soil:Using config(s): quickstart
INFO:soil:Dumping results to soil_output/quickstart : ['csv', 'gexf']
INFO:soil:Starting simulation quickstart at 13:35:30.
INFO:soil:Starting Simulation quickstart trial 0 at 13:35:30.
INFO:soil:Finished Simulation quickstart trial 0 at 13:35:49 in 19.43677067756653 seconds
INFO:soil:Starting Dumping simulation quickstart trial 0 at 13:35:49.
INFO:soil:Finished Dumping simulation quickstart trial 0 at 13:35:51 in 1.7733407020568848 seconds
INFO:soil:Dumping results to soil_output/quickstart
INFO:soil:Finished simulation quickstart at 13:35:51 in 21.29862952232361 seconds
The ``CSV`` file should look like this:
.. code::
agent_id,t_step,key,value
env,0,neutral_discontent_spon_prob,0.05
env,0,neutral_discontent_infected_prob,0.1
env,0,neutral_content_spon_prob,0.2
env,0,neutral_content_infected_prob,0.4
env,0,discontent_neutral,0.2
env,0,discontent_content,0.05
env,0,content_discontent,0.05
env,0,variance_d_c,0.05
env,0,variance_c_d,0.1
Results and visualization
=========================
The environment variables are marked as ``agent_id`` env.
Th exported values are only stored when they change.
To find out how to get every key and value at every point in the simulation, check out the :doc:`soil_tutorial`.
The dynamic graph is exported as a .gexf file which could be visualized with
`Gephi <https://gephi.org/users/download/>`__.
Now it is your turn to experiment with the simulation.
Change some of the parameters, such as the number of agents, the probability of becoming content, or the type of network, and see how the results change.
Soil also includes a web server that allows you to upload your simulations, change parameters, and visualize the results, including a timeline of the network.
To make it work, you have to install soil like this:
.. code::
pip install soil[web]
Once installed, the soil web UI can be run in two ways:
.. code::
soil-web
# OR
python -m soil.web

View File

@@ -1,33 +0,0 @@
---
name: quickstart
num_trials: 1
max_time: 1000
model_params:
agents:
- agent_class: SISaModel
topology: true
state:
id: neutral
weight: 1
- agent_class: SISaModel
topology: true
state:
id: content
weight: 2
topology:
params:
n: 100
k: 5
p: 0.2
generator: newman_watts_strogatz_graph
neutral_discontent_spon_prob: 0.05
neutral_discontent_infected_prob: 0.1
neutral_content_spon_prob: 0.2
neutral_content_infected_prob: 0.4
discontent_neutral: 0.2
discontent_content: 0.05
content_discontent: 0.05
variance_d_c: 0.05
variance_c_d: 0.1
content_neutral: 0.1
standard_variance: 0.1

View File

@@ -1 +1,2 @@
ipython>=7.31.1
nbsphinx==0.9.1

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

1
examples/README.md Normal file
View File

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

View File

@@ -43,7 +43,7 @@ class Journey:
driver: Optional[Driver] = None
class City(EventedEnvironment):
class City(Environment):
"""
An environment with a grid where drivers and passengers will be placed.
@@ -85,40 +85,41 @@ class Driver(Evented, FSM):
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
# TODO: remove
# def on_receive(self, msg, sender):
# """This is not a state. It will run (and block) every time process_messages is invoked"""
# if self.journey is None and isinstance(msg, Journey) and msg.driver is None:
# msg.driver = self
# self.journey = msg
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()
self.debug(f"Passengers left {c}")
return c
@default_state
@state
def wandering(self):
@state(default=True)
async def wandering(self):
"""Move around the city until a journey is accepted"""
target = None
self.check_passengers()
if not self.check_passengers():
return self.die("No passengers left")
self.journey = None
while self.journey is None: # No potential journeys detected (see on_receive)
while self.journey is None: # No potential journeys detected
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()
if not self.check_passengers():
return self.die("No passengers left")
# This will call on_receive behind the scenes, and the agent's status will be updated
self.check_messages()
yield Delta(30) # Wait at least 30 seconds before checking again
await self.delay(30) # Wait at least 30 seconds before checking again
try:
# Re-send the journey to the passenger, to confirm that we have been selected
self.journey = yield self.journey.passenger.ask(self.journey, timeout=60)
self.journey = await self.journey.passenger.ask(self.journey, timeout=60, delay=5)
except events.TimedOut:
# No journey has been accepted. Try again
self.journey = None
@@ -127,20 +128,24 @@ class Driver(Evented, FSM):
return self.driving
@state
def driving(self):
async def driving(self):
"""The journey has been accepted. Pick them up and take them to their destination"""
self.info(f"Driving towards Passenger {self.journey.passenger.unique_id}")
while self.move_towards(self.journey.origin):
yield
await self.delay()
self.info(f"Driving {self.journey.passenger.unique_id} from {self.journey.origin} to {self.journey.destination}")
while self.move_towards(self.journey.destination, with_passenger=True):
yield
await self.delay()
self.info("Arrived at destination")
self.earnings += self.journey.tip
self.model.total_earnings += self.journey.tip
self.check_passengers()
if not self.check_passengers():
return self.die("No passengers left")
return self.wandering
def move_towards(self, target, with_passenger=False):
"""Move one cell at a time towards a target"""
self.info(f"Moving { self.pos } -> { target }")
self.debug(f"Moving { self.pos } -> { target }")
if target[0] == self.pos[0] and target[1] == self.pos[1]:
return False
@@ -163,19 +168,20 @@ class Driver(Evented, FSM):
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"""
# TODO: Remove
# def on_receive(self, msg, sender):
# """This is not a state. It will be run synchronously every time `process_messages` is run"""
if isinstance(msg, Journey):
self.journey = msg
return msg
# if isinstance(msg, Journey):
# self.journey = msg
# return msg
@default_state
@state
def asking(self):
async def asking(self):
destination = (
self.random.randint(0, self.model.grid.height),
self.random.randint(0, self.model.grid.width),
self.random.randint(0, self.model.grid.height-1),
self.random.randint(0, self.model.grid.width-1),
)
self.journey = None
journey = Journey(
@@ -187,29 +193,48 @@ class Passenger(Evented, FSM):
timeout = 60
expiration = self.now + timeout
self.model.broadcast(journey, ttl=timeout, sender=self, agent_class=Driver)
self.info(f"Asking for journey at: { self.pos }")
self.broadcast(journey, ttl=timeout, agent_class=Driver)
while not self.journey:
self.info(f"Passenger at: { self.pos }. Checking for responses.")
self.debug(f"Waiting for responses at: { self.pos }")
try:
# This will call check_messages behind the scenes, and the agent's status will be updated
# If you want to avoid that, you can call it with: check=False
yield self.received(expiration=expiration)
offers = await self.received(expiration=expiration, delay=10)
accepted = None
for event in offers:
offer = event.payload
if isinstance(offer, Journey):
self.journey = offer
assert isinstance(event.sender, Driver)
try:
answer = await event.sender.ask(True, sender=self, timeout=60, delay=5)
if answer:
accepted = offer
self.journey = offer
break
except events.TimedOut:
pass
if accepted:
for event in offers:
if event.payload != accepted:
event.sender.tell(False, timeout=60, delay=5)
except events.TimedOut:
self.info(f"Passenger at: { self.pos }. Asking for journey.")
self.model.broadcast(
journey, ttl=timeout, sender=self, agent_class=Driver
self.info(f"Still no response. Waiting at: { self.pos }")
self.broadcast(
journey, ttl=timeout, agent_class=Driver
)
expiration = self.now + timeout
self.info(f"Got a response! Waiting for driver")
return self.driving_home
@state
def driving_home(self):
async def driving_home(self):
while (
self.pos[0] != self.journey.destination[0]
or self.pos[1] != self.journey.destination[1]
):
try:
yield self.received(timeout=60)
await self.received(timeout=60)
except events.TimedOut:
pass
@@ -220,7 +245,7 @@ simulation = Simulation(name="RideHailing",
model=City,
seed="carsSeed",
max_time=1000,
model_params=dict(n_passengers=2))
parameters=dict(n_passengers=2))
if __name__ == "__main__":
easy(simulation)
easy(simulation)

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ chart = ChartModule(
[{"Label": "Gini", "Color": "Black"}], data_collector_name="datacollector"
)
model_params = {
parameters = {
"N": Slider(
"N",
5,
@@ -98,12 +98,12 @@ model_params = {
canvas_element = CanvasGrid(
gridPortrayal, model_params["width"].value, model_params["height"].value, 500, 500
gridPortrayal, parameters["width"].value, parameters["height"].value, 500, 500
)
server = ModularServer(
MoneyEnv, [grid, chart, canvas_element], "Money Model", model_params
MoneyEnv, [grid, chart, canvas_element], "Money Model", parameters
)
server.port = 8521

View File

@@ -2,13 +2,12 @@
"cells": [
{
"cell_type": "code",
"execution_count": 4,
"execution_count": 1,
"metadata": {
"ExecuteTime": {
"end_time": "2017-11-08T16:22:30.732107Z",
"start_time": "2017-11-08T17:22:30.059855+01:00"
},
"collapsed": true
}
},
"outputs": [],
"source": [
@@ -28,24 +27,16 @@
},
{
"cell_type": "code",
"execution_count": 5,
"execution_count": 2,
"metadata": {
"ExecuteTime": {
"end_time": "2017-11-08T16:22:35.580593Z",
"start_time": "2017-11-08T17:22:35.542745+01:00"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Populating the interactive namespace from numpy and matplotlib\n"
]
}
],
"outputs": [],
"source": [
"%pylab inline\n",
"%matplotlib inline\n",
"\n",
"from soil import *"
]
@@ -66,7 +57,7 @@
},
{
"cell_type": "code",
"execution_count": 6,
"execution_count": 3,
"metadata": {
"ExecuteTime": {
"end_time": "2017-11-08T16:22:37.242327Z",
@@ -86,7 +77,7 @@
" prob_neighbor_spread: 0.0\r\n",
" prob_tv_spread: 0.01\r\n",
"interval: 1\r\n",
"max_time: 30\r\n",
"max_time: 300\r\n",
"name: Sim_all_dumb\r\n",
"network_agents:\r\n",
"- agent_class: DumbViewer\r\n",
@@ -110,7 +101,7 @@
" prob_neighbor_spread: 0.0\r\n",
" prob_tv_spread: 0.01\r\n",
"interval: 1\r\n",
"max_time: 30\r\n",
"max_time: 300\r\n",
"name: Sim_half_herd\r\n",
"network_agents:\r\n",
"- agent_class: DumbViewer\r\n",
@@ -142,18 +133,18 @@
" prob_neighbor_spread: 0.0\r\n",
" prob_tv_spread: 0.01\r\n",
"interval: 1\r\n",
"max_time: 30\r\n",
"max_time: 300\r\n",
"name: Sim_all_herd\r\n",
"network_agents:\r\n",
"- agent_class: HerdViewer\r\n",
" state:\r\n",
" has_tv: true\r\n",
" id: neutral\r\n",
" state_id: neutral\r\n",
" weight: 1\r\n",
"- agent_class: HerdViewer\r\n",
" state:\r\n",
" has_tv: true\r\n",
" id: neutral\r\n",
" state_id: neutral\r\n",
" weight: 1\r\n",
"network_params:\r\n",
" generator: barabasi_albert_graph\r\n",
@@ -169,13 +160,13 @@
" prob_tv_spread: 0.01\r\n",
" prob_neighbor_cure: 0.1\r\n",
"interval: 1\r\n",
"max_time: 30\r\n",
"max_time: 300\r\n",
"name: Sim_wise_herd\r\n",
"network_agents:\r\n",
"- agent_class: HerdViewer\r\n",
" state:\r\n",
" has_tv: true\r\n",
" id: neutral\r\n",
" state_id: neutral\r\n",
" weight: 1\r\n",
"- agent_class: WiseViewer\r\n",
" state:\r\n",
@@ -195,13 +186,13 @@
" prob_tv_spread: 0.01\r\n",
" prob_neighbor_cure: 0.1\r\n",
"interval: 1\r\n",
"max_time: 30\r\n",
"max_time: 300\r\n",
"name: Sim_all_wise\r\n",
"network_agents:\r\n",
"- agent_class: WiseViewer\r\n",
" state:\r\n",
" has_tv: true\r\n",
" id: neutral\r\n",
" state_id: neutral\r\n",
" weight: 1\r\n",
"- agent_class: WiseViewer\r\n",
" state:\r\n",
@@ -225,7 +216,7 @@
},
{
"cell_type": "code",
"execution_count": 22,
"execution_count": 4,
"metadata": {
"ExecuteTime": {
"end_time": "2017-11-08T18:07:46.781745Z",
@@ -233,7 +224,24 @@
},
"scrolled": true
},
"outputs": [],
"outputs": [
{
"ename": "ValueError",
"evalue": "No objects to concatenate",
"output_type": "error",
"traceback": [
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
"\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)",
"Cell \u001b[0;32mIn[4], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m evodumb \u001b[38;5;241m=\u001b[39m \u001b[43manalysis\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mread_data\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43msoil_output/Sim_all_dumb/\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mprocess\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43manalysis\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mget_count\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mgroup\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;28;43;01mTrue\u001b[39;49;00m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mkeys\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mid\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m;\n",
"File \u001b[0;32m/mnt/data/home/j/git/lab.gsi/soil/soil/soil/analysis.py:14\u001b[0m, in \u001b[0;36mread_data\u001b[0;34m(group, *args, **kwargs)\u001b[0m\n\u001b[1;32m 12\u001b[0m iterable \u001b[38;5;241m=\u001b[39m _read_data(\u001b[38;5;241m*\u001b[39margs, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs)\n\u001b[1;32m 13\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m group:\n\u001b[0;32m---> 14\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mgroup_trials\u001b[49m\u001b[43m(\u001b[49m\u001b[43miterable\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 15\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[1;32m 16\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28mlist\u001b[39m(iterable)\n",
"File \u001b[0;32m/mnt/data/home/j/git/lab.gsi/soil/soil/soil/analysis.py:201\u001b[0m, in \u001b[0;36mgroup_trials\u001b[0;34m(trials, aggfunc)\u001b[0m\n\u001b[1;32m 199\u001b[0m trials \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mlist\u001b[39m(trials)\n\u001b[1;32m 200\u001b[0m trials \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mlist\u001b[39m(\u001b[38;5;28mmap\u001b[39m(\u001b[38;5;28;01mlambda\u001b[39;00m x: x[\u001b[38;5;241m1\u001b[39m] \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(x, \u001b[38;5;28mtuple\u001b[39m) \u001b[38;5;28;01melse\u001b[39;00m x, trials))\n\u001b[0;32m--> 201\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mpd\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mconcat\u001b[49m\u001b[43m(\u001b[49m\u001b[43mtrials\u001b[49m\u001b[43m)\u001b[49m\u001b[38;5;241m.\u001b[39mgroupby(level\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m0\u001b[39m)\u001b[38;5;241m.\u001b[39magg(aggfunc)\u001b[38;5;241m.\u001b[39mreorder_levels([\u001b[38;5;241m2\u001b[39m, \u001b[38;5;241m0\u001b[39m,\u001b[38;5;241m1\u001b[39m] ,axis\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m1\u001b[39m)\n",
"File \u001b[0;32m/mnt/data/home/j/git/lab.gsi/soil/soil/.env-v0.20/lib/python3.8/site-packages/pandas/util/_decorators.py:331\u001b[0m, in \u001b[0;36mdeprecate_nonkeyword_arguments.<locals>.decorate.<locals>.wrapper\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 325\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(args) \u001b[38;5;241m>\u001b[39m num_allow_args:\n\u001b[1;32m 326\u001b[0m warnings\u001b[38;5;241m.\u001b[39mwarn(\n\u001b[1;32m 327\u001b[0m msg\u001b[38;5;241m.\u001b[39mformat(arguments\u001b[38;5;241m=\u001b[39m_format_argument_list(allow_args)),\n\u001b[1;32m 328\u001b[0m \u001b[38;5;167;01mFutureWarning\u001b[39;00m,\n\u001b[1;32m 329\u001b[0m stacklevel\u001b[38;5;241m=\u001b[39mfind_stack_level(),\n\u001b[1;32m 330\u001b[0m )\n\u001b[0;32m--> 331\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n",
"File \u001b[0;32m/mnt/data/home/j/git/lab.gsi/soil/soil/.env-v0.20/lib/python3.8/site-packages/pandas/core/reshape/concat.py:368\u001b[0m, in \u001b[0;36mconcat\u001b[0;34m(objs, axis, join, ignore_index, keys, levels, names, verify_integrity, sort, copy)\u001b[0m\n\u001b[1;32m 146\u001b[0m \u001b[38;5;129m@deprecate_nonkeyword_arguments\u001b[39m(version\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m, allowed_args\u001b[38;5;241m=\u001b[39m[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mobjs\u001b[39m\u001b[38;5;124m\"\u001b[39m])\n\u001b[1;32m 147\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mconcat\u001b[39m(\n\u001b[1;32m 148\u001b[0m objs: Iterable[NDFrame] \u001b[38;5;241m|\u001b[39m Mapping[HashableT, NDFrame],\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 157\u001b[0m copy: \u001b[38;5;28mbool\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[1;32m 158\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m DataFrame \u001b[38;5;241m|\u001b[39m Series:\n\u001b[1;32m 159\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 160\u001b[0m \u001b[38;5;124;03m Concatenate pandas objects along a particular axis.\u001b[39;00m\n\u001b[1;32m 161\u001b[0m \n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 366\u001b[0m \u001b[38;5;124;03m 1 3 4\u001b[39;00m\n\u001b[1;32m 367\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[0;32m--> 368\u001b[0m op \u001b[38;5;241m=\u001b[39m \u001b[43m_Concatenator\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 369\u001b[0m \u001b[43m \u001b[49m\u001b[43mobjs\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 370\u001b[0m \u001b[43m \u001b[49m\u001b[43maxis\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43maxis\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 371\u001b[0m \u001b[43m \u001b[49m\u001b[43mignore_index\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mignore_index\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 372\u001b[0m \u001b[43m \u001b[49m\u001b[43mjoin\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mjoin\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 373\u001b[0m \u001b[43m \u001b[49m\u001b[43mkeys\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mkeys\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 374\u001b[0m \u001b[43m \u001b[49m\u001b[43mlevels\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mlevels\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 375\u001b[0m \u001b[43m \u001b[49m\u001b[43mnames\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mnames\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 376\u001b[0m \u001b[43m \u001b[49m\u001b[43mverify_integrity\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mverify_integrity\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 377\u001b[0m \u001b[43m \u001b[49m\u001b[43mcopy\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mcopy\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 378\u001b[0m \u001b[43m \u001b[49m\u001b[43msort\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43msort\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 379\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 381\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m op\u001b[38;5;241m.\u001b[39mget_result()\n",
"File \u001b[0;32m/mnt/data/home/j/git/lab.gsi/soil/soil/.env-v0.20/lib/python3.8/site-packages/pandas/core/reshape/concat.py:425\u001b[0m, in \u001b[0;36m_Concatenator.__init__\u001b[0;34m(self, objs, axis, join, keys, levels, names, ignore_index, verify_integrity, copy, sort)\u001b[0m\n\u001b[1;32m 422\u001b[0m objs \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mlist\u001b[39m(objs)\n\u001b[1;32m 424\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(objs) \u001b[38;5;241m==\u001b[39m \u001b[38;5;241m0\u001b[39m:\n\u001b[0;32m--> 425\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mNo objects to concatenate\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 427\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m keys \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[1;32m 428\u001b[0m objs \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mlist\u001b[39m(com\u001b[38;5;241m.\u001b[39mnot_none(\u001b[38;5;241m*\u001b[39mobjs))\n",
"\u001b[0;31mValueError\u001b[0m: No objects to concatenate"
]
}
],
"source": [
"evodumb = analysis.read_data('soil_output/Sim_all_dumb/', process=analysis.get_count, group=True, keys=['id']);"
]
@@ -721,9 +729,9 @@
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"display_name": "venv-soil",
"language": "python",
"name": "python3"
"name": "venv-soil"
},
"language_info": {
"codemirror_mode": {
@@ -735,7 +743,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.2"
"version": "3.8.10"
},
"toc": {
"colors": {

View File

@@ -1,4 +1,4 @@
from soil.agents import FSM, NetworkAgent, state, default_state, prob
from soil.agents import FSM, NetworkAgent, state, default_state
from soil.parameters import *
import logging
@@ -116,7 +116,7 @@ for [r1, r2] in product([0, 0.5, 1.0], repeat=2):
Simulation(
name='newspread_sim',
model=NewsSpread,
model_params=dict(
parameters=dict(
ratio_dumb=r1,
ratio_herd=r2,
ratio_wise=1-r1-r2,
@@ -124,7 +124,7 @@ for [r1, r2] in product([0, 0.5, 1.0], repeat=2):
network_params=netparams,
prob_neighbor_spread=0,
),
num_trials=5,
iterations=5,
max_steps=300,
dump=False,
).run()

View File

@@ -38,7 +38,7 @@ simulation = Simulation(
name="Programmatic",
model=ProgrammaticEnv,
seed='Program',
num_trials=1,
iterations=1,
max_time=100,
dump=False,
)

View File

@@ -178,10 +178,10 @@ class Police(FSM):
sim = Simulation(
model=CityPubs,
name="pubcrawl",
num_trials=3,
iterations=3,
max_steps=10,
dump=False,
model_params=dict(
parameters=dict(
network_generator=nx.empty_graph,
network_params={"n": 30},
model=CityPubs,

View File

@@ -1,7 +1,7 @@
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.
- `improved`. Using more advanced features such as the delays to avoid unnecessary computations (i.e., skip steps).
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`:

View File

@@ -1,23 +1,33 @@
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
from soil import Evented, FSM, state, default_state, BaseAgent, NetworkAgent, Environment, parameters, report, TimedOut
import math
from rabbits_basic_sim import RabbitEnv
class RabbitsImprovedEnv(Environment):
prob_death: parameters.probability = 1e-3
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)
@report
@property
def num_rabbits(self):
return self.count_agents(agent_class=Rabbit)
class Rabbit(FSM, NetworkAgent):
@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)
class Rabbit(Evented, FSM, NetworkAgent):
sexual_maturity = 30
life_expectancy = 300
@@ -32,42 +42,40 @@ class Rabbit(FSM, NetworkAgent):
@default_state
@state
def newborn(self):
self.info("I am a newborn.")
self.debug("I am a newborn.")
self.birth = self.now
self.offspring = 0
return self.youngling, Delta(self.sexual_maturity - self.age)
return self.youngling
@state
def youngling(self):
if self.age >= self.sexual_maturity:
self.info(f"I am fertile! My age is {self.age}")
return self.fertile
async def youngling(self):
self.debug("I am a youngling.")
await self.delay(self.sexual_maturity - self.age)
assert self.age >= self.sexual_maturity
self.debug(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")
@state
def dead(self):
self.die()
class Male(Rabbit):
max_females = 5
mating_prob = 0.001
mating_prob = 0.005
@state
def fertile(self):
if self.age > self.life_expectancy:
return self.dead
return self.die()
# 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)
self.debug(f"FOUND A FEMALE: {repr(f)}. Mating with prob {self.mating_prob}")
if self.prob(self["mating_prob"]):
f.impregnate(self)
f.tell(self.unique_id, sender=self, timeout=1)
break # Do not try to impregnate other females
@@ -76,78 +84,91 @@ class Female(Rabbit):
conception = None
@state
def fertile(self):
async 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"impregnated by {repr(male)}")
self.mate = male
self.conception = self.now
self.number_of_babies = int(8 + 4 * self.random.random())
try:
timeout = self.life_expectancy - self.age
while timeout > 0:
mates = await self.received(timeout=timeout)
# assert self.age <= self.life_expectancy
for mate in mates:
try:
male = self.model.agents[mate.payload]
except ValueError:
continue
self.debug(f"impregnated by {repr(male)}")
self.mate = male
self.number_of_babies = int(8 + 4 * self.random.random())
self.conception = self.now
return self.pregnant
except TimedOut:
pass
return self.die()
@state
def pregnant(self):
async def pregnant(self):
self.debug("I am pregnant")
# assert self.mate is not None
when = min(self.gestation, self.life_expectancy - self.age)
if when < 0:
return self.die()
await self.delay(when)
if self.age > self.life_expectancy:
self.info("Dying before giving birth")
self.debug("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")
# assert self.now - self.conception >= self.gestation
if not self.alive:
return self.die()
self.offspring += 1
self.mate = None
return self.fertile
self.debug("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")
self.offspring += 1
self.mate = None
self.conception = None
return self.fertile
def die(self):
if self.pregnancy is not None:
self.info("A mother has died carrying a baby!!")
if self.conception is not None:
self.debug("A mother has died carrying a baby!!")
return super().die()
class RandomAccident(BaseAgent):
# Default value, but the value from the environment takes precedence
prob_death = 1e-3
def step(self):
rabbits_alive = self.model.G.number_of_nodes()
if not rabbits_alive:
return self.die()
alive = self.get_agents(agent_class=Rabbit, alive=True)
prob_death = self.model.get("prob_death", 1e-100) * math.floor(
math.log10(max(1, rabbits_alive))
)
if not alive:
return self.die("No more rabbits to kill")
num_alive = len(alive)
prob_death = min(1, self.prob_death * num_alive/10)
self.debug("Killing some rabbits with prob={}!".format(prob_death))
for i in self.iter_agents(agent_class=Rabbit):
for i in alive:
if i.state_id == i.dead.id:
continue
if self.prob(prob_death):
self.info("I killed a rabbit: {}".format(i.id))
rabbits_alive -= 1
i.die()
self.debug("Rabbits alive: {}".format(rabbits_alive))
self.debug("I killed a rabbit: {}".format(i.unique_id))
num_alive -= 1
self.model.remove_agent(i)
self.debug("Rabbits alive: {}".format(num_alive))
sim = Simulation(model=RabbitsImprovedEnv, max_time=100, seed="MySeed", num_trials=1)
if __name__ == "__main__":
sim.run()
RabbitsImprovedEnv.run(max_time=1000, seed="MySeed", iterations=1)

View File

@@ -1,11 +1,9 @@
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, Simulation, report, parameters as params
from collections import Counter
import logging
from soil import FSM, state, default_state, BaseAgent, NetworkAgent, Environment, report, parameters as params
import math
class RabbitEnv(Environment):
prob_death: params.probability = 1e-100
prob_death: params.probability = 1e-3
def init(self):
a1 = self.add_node(Male)
@@ -37,7 +35,7 @@ class Rabbit(NetworkAgent, FSM):
@default_state
@state
def newborn(self):
self.info("I am a newborn.")
self.debug("I am a newborn.")
self.age = 0
self.offspring = 0
return self.youngling
@@ -46,7 +44,7 @@ class Rabbit(NetworkAgent, FSM):
def youngling(self):
self.age += 1
if self.age >= self.sexual_maturity:
self.info(f"I am fertile! My age is {self.age}")
self.debug(f"I am fertile! My age is {self.age}")
return self.fertile
@state
@@ -60,7 +58,7 @@ class Rabbit(NetworkAgent, FSM):
class Male(Rabbit):
max_females = 5
mating_prob = 0.001
mating_prob = 0.005
@state
def fertile(self):
@@ -70,9 +68,8 @@ 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
):
for f in self.model.agents.filter(
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)
@@ -93,14 +90,14 @@ class Female(Rabbit):
return self.pregnant
def impregnate(self, male):
self.info(f"impregnated by {repr(male)}")
self.debug(f"impregnated by {repr(male)}")
self.mate = male
self.pregnancy = 0
self.number_of_babies = int(8 + 4 * self.random.random())
@state
def pregnant(self):
self.info("I am pregnant")
self.debug("I am pregnant")
self.age += 1
if self.age >= self.life_expectancy:
@@ -110,7 +107,7 @@ class Female(Rabbit):
self.pregnancy += 1
return
self.info("Having {} babies".format(self.number_of_babies))
self.debug("Having {} babies".format(self.number_of_babies))
for i in range(self.number_of_babies):
state = {}
agent_class = self.random.choice([Male, Female])
@@ -129,33 +126,32 @@ class Female(Rabbit):
def die(self):
if "pregnancy" in self and self["pregnancy"] > -1:
self.info("A mother has died carrying a baby!!")
self.debug("A mother has died carrying a baby!!")
return super().die()
class RandomAccident(BaseAgent):
prob_death = None
def step(self):
rabbits_alive = self.model.G.number_of_nodes()
alive = self.get_agents(agent_class=Rabbit, alive=True)
if not rabbits_alive:
return self.die()
if not alive:
return self.die("No more rabbits to kill")
prob_death = self.model.prob_death * math.floor(
math.log10(max(1, rabbits_alive))
)
num_alive = len(alive)
prob_death = min(1, self.prob_death * num_alive/10)
self.debug("Killing some rabbits with prob={}!".format(prob_death))
for i in self.get_agents(agent_class=Rabbit):
for i in alive:
if i.state_id == i.dead.id:
continue
if self.prob(prob_death):
self.info("I killed a rabbit: {}".format(i.id))
rabbits_alive -= 1
i.die()
self.debug("Rabbits alive: {}".format(rabbits_alive))
self.debug("I killed a rabbit: {}".format(i.unique_id))
num_alive -= 1
self.model.remove_agent(i)
i.alive = False
i.killed = True
self.debug("Rabbits alive: {}".format(num_alive))
sim = Simulation(model=RabbitEnv, max_time=100, seed="MySeed", num_trials=1)
if __name__ == "__main__":
sim.run()
RabbitEnv.run(max_time=1000, seed="MySeed", iterations=1)

View File

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

View File

@@ -1,7 +1,9 @@
import networkx as nx
from soil.agents import Geo, NetworkAgent, FSM, custom, state, default_state
from soil.agents import FSM, state, default_state
from soil.agents.geo import Geo
from soil import Environment, Simulation
from soil.parameters import *
from soil.utils import int_seed
class TerroristEnvironment(Environment):
@@ -38,9 +40,8 @@ class TerroristEnvironment(Environment):
HavenModel
], [self.ratio_civil, self.ratio_leader, self.ratio_training, self.ratio_haven])
@staticmethod
def generator(*args, **kwargs):
return nx.random_geometric_graph(*args, **kwargs)
def generator(self, *args, seed=None, **kwargs):
return nx.random_geometric_graph(*args, **kwargs, seed=seed or int_seed(self._seed))
class TerroristSpreadModel(FSM, Geo):
"""
@@ -137,7 +138,7 @@ class TerroristSpreadModel(FSM, Geo):
def ego_search(self, steps=1, center=False, agent=None, **kwargs):
"""Get a list of nodes in the ego network of *node* of radius *steps*"""
node = agent.node_id
node = agent.node_id if agent else self.node_id
G = self.subgraph(**kwargs)
return nx.ego_graph(G, node, center=center, radius=steps).nodes()
@@ -279,26 +280,26 @@ class TerroristNetworkModel(TerroristSpreadModel):
)
)
neighbours = set(
agent.id
agent.unique_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)
social_distance = 1 / self.shortest_path_length(agent.unique_id)
spatial_proximity = 1 - self.get_distance(agent.unique_id)
prob_new_interaction = (
self.weight_social_distance * social_distance
+ self.weight_link_distance * spatial_proximity
)
if (
agent["id"] == agent.civilian.id
agent.state_id == "civilian"
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]
source_x, source_y = nx.get_node_attributes(self.G, "pos")[self.unique_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)
@@ -306,16 +307,17 @@ class TerroristNetworkModel(TerroristSpreadModel):
def shortest_path_length(self, target):
try:
return nx.shortest_path_length(self.G, self.id, target)
return nx.shortest_path_length(self.G, self.unique_id, target)
except nx.NetworkXNoPath:
return float("inf")
sim = Simulation(
model=TerroristEnvironment,
num_trials=1,
iterations=1,
name="TerroristNetworkModel_sim",
max_steps=150,
seed="default2",
skip_test=False,
dump=False,
)

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@ pandas>=1
SALib>=1.3
Jinja2
Mesa>=1.2
pydantic>=1.9
sqlalchemy>=1.4
typing-extensions>=4.4
annotated-types>=0.4
annotated-types>=0.4
tqdm>=4.64

View File

@@ -1,3 +1,7 @@
[metadata]
long_description = file: README.md
long_description_content_type = text/markdown
[aliases]
test=pytest
[tool:pytest]

View File

@@ -17,9 +17,9 @@ def parse_requirements(filename):
install_reqs = parse_requirements("requirements.txt")
test_reqs = parse_requirements("test-requirements.txt")
extras_require={
'mesa': ['mesa>=0.8.9'],
'geo': ['scipy>=1.3'],
'web': ['tornado']
'web': ['tornado'],
'ipython': ['ipython==8.12', 'nbformat==5.8'],
}
extras_require['all'] = [dep for package in extras_require.values() for dep in package]

View File

@@ -1 +1 @@
0.30.0rc4
1.0.0rc10

View File

@@ -19,7 +19,7 @@ from pathlib import Path
from .agents import *
from . import agents
from .simulation import *
from .environment import Environment, EventedEnvironment
from .environment import Environment
from .datacollection import SoilCollector
from . import serialization
from .utils import logger
@@ -87,7 +87,7 @@ def main(
"--graph",
"-g",
action="store_true",
help="Dump each trial's network topology as a GEXF graph. Defaults to false.",
help="Dump each iteration's network topology as a GEXF graph. Defaults to false.",
)
parser.add_argument(
"--csv",
@@ -116,11 +116,23 @@ def main(
help="Export environment and/or simulations using this exporter",
)
parser.add_argument(
"--until",
"--max_time",
default="",
help="Set maximum time for the simulation to run. ",
)
parser.add_argument(
"--max_steps",
default="",
help="Set maximum number of steps for the simulation to run.",
)
parser.add_argument(
"--iterations",
default="",
help="Set maximum number of iterations (runs) for the simulation.",
)
parser.add_argument(
"--seed",
default=None,
@@ -147,7 +159,8 @@ def main(
)
args = parser.parse_args()
logger.setLevel(getattr(logging, (args.level or "INFO").upper()))
level = getattr(logging, (args.level or "INFO").upper())
logger.setLevel(level)
if args.version:
return
@@ -185,11 +198,14 @@ def main(
debug=debug,
exporters=exporters,
num_processes=args.num_processes,
level=level,
outdir=output,
exporter_params=exp_params,
**kwargs)
if args.seed is not None:
opts["seed"] = args.seed
if args.iterations:
opts["iterations"] =int(args.iterations)
if sim:
logger.info("Loading simulation instance")
@@ -218,7 +234,7 @@ def main(
k, v = s.split("=", 1)[:2]
v = eval(v)
tail, *head = k.rsplit(".", 1)[::-1]
target = sim.model_params
target = sim.parameters
if head:
for part in head[0].split("."):
try:
@@ -233,12 +249,16 @@ def main(
if args.only_convert:
print(sim.to_yaml())
continue
res.append(sim.run(until=args.until))
d = {}
if args.max_time:
d["max_time"] = float(args.max_time)
if args.max_steps:
d["max_steps"] = int(args.max_steps)
res.append(sim.run(**d))
except Exception as ex:
if args.pdb:
from .debugging import post_mortem
print(traceback.format_exc())
post_mortem()
else:

View File

@@ -1,85 +1,23 @@
from __future__ import annotations
import logging
from collections import OrderedDict, defaultdict
from collections.abc import MutableMapping, Mapping, Set
from abc import ABCMeta
from copy import deepcopy, copy
from functools import partial, wraps
from itertools import islice, chain
from collections.abc import MutableMapping
from copy import deepcopy
import inspect
import types
import textwrap
import networkx as nx
import warnings
import sys
from typing import Any
from mesa import Agent as MesaAgent
from mesa import Agent as MesaAgent, Model
from typing import Dict, List
from .. import utils, time
from .. import serialization, network, utils, time, config
from .meta import MetaAgent
IGNORED_FIELDS = ("model", "logger")
class MetaAgent(ABCMeta):
def __new__(mcls, name, bases, namespace):
defaults = {}
# Re-use defaults from inherited classes
for i in bases:
if isinstance(i, MetaAgent):
defaults.update(i._defaults)
new_nmspc = {
"_defaults": defaults,
"_last_return": None,
"_last_except": None,
}
for attr, func in namespace.items():
if attr == "step" and inspect.isgeneratorfunction(func):
orig_func = func
new_nmspc["_coroutine"] = None
@wraps(func)
def func(self):
while True:
if not self._coroutine:
self._coroutine = orig_func(self)
try:
if self._last_except:
return self._coroutine.throw(self._last_except)
else:
return self._coroutine.send(self._last_return)
except StopIteration as ex:
self._coroutine = None
return ex.value
finally:
self._last_return = None
self._last_except = None
func.id = name or func.__name__
func.is_default = False
new_nmspc[attr] = func
elif (
isinstance(func, types.FunctionType)
or isinstance(func, property)
or isinstance(func, classmethod)
or attr[0] == "_"
):
new_nmspc[attr] = func
elif attr == "defaults":
defaults.update(func)
else:
defaults[attr] = copy(func)
return super().__new__(mcls=mcls, name=name, bases=bases, namespace=new_nmspc)
class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
"""
A special type of Mesa Agent that:
@@ -92,8 +30,11 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
Any attribute that is not preceded by an underscore (`_`) will also be added to its state.
"""
def __init__(self, unique_id, model, name=None, init=True, interval=None, **kwargs):
assert isinstance(unique_id, int)
def __init__(self, unique_id=None, model=None, name=None, init=True, **kwargs):
# Ideally, model should be the first argument, but Mesa's Agent class has unique_id first
assert not (model is None), "Must provide a model"
if unique_id is None:
unique_id = model.next_id()
super().__init__(unique_id=unique_id, model=model)
self.name = (
@@ -102,7 +43,6 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
self.alive = True
self.interval = interval or self.get("interval", 1)
logger = utils.logger.getChild(getattr(self.model, "id", self.model)).getChild(
self.name
)
@@ -111,13 +51,18 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
if hasattr(self, "level"):
self.logger.setLevel(self.level)
for k in self._defaults:
v = getattr(model, k, None)
if v is not None:
setattr(self, k, v)
for (k, v) in self._defaults.items():
if not hasattr(self, k) or getattr(self, k) is None:
setattr(self, k, deepcopy(v))
for (k, v) in kwargs.items():
setattr(self, k, v)
if init:
self.init()
@@ -128,11 +73,11 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
return hash(self.unique_id)
def prob(self, probability):
return prob(probability, self.model.random)
return utils.prob(probability, self.model.random)
@classmethod
def w(cls, **kwargs):
return custom(cls, **kwargs)
return utils.custom(cls, **kwargs)
# TODO: refactor to clean up mesa compatibility
@property
@@ -142,20 +87,12 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
print(msg, file=sys.stderr)
return self.unique_id
@classmethod
def from_dict(cls, model, attrs, warn_extra=True):
ignored = {}
args = {}
for k, v in attrs.items():
if k in inspect.signature(cls).parameters:
args[k] = v
else:
ignored[k] = v
if ignored and warn_extra:
utils.logger.info(
f"Ignoring the following arguments for agent class { agent_class.__name__ }: { ignored }"
)
return cls(model=model, **args)
@property
def env(self):
msg = "This attribute is deprecated. Use `model` instead"
warnings.warn(msg, DeprecationWarning)
print(msg, file=sys.stderr)
return self.model
def __getitem__(self, key):
try:
@@ -189,11 +126,13 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
return it
def get(self, key, default=None):
if key in self:
return self[key]
elif key in self.model:
return self.model[key]
return default
try:
return getattr(self, key)
except AttributeError:
try:
return getattr(self.model, key)
except AttributeError:
return default
@property
def now(self):
@@ -206,21 +145,18 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def die(self, msg=None):
if msg:
self.info("Agent dying:", msg)
self.debug(f"agent dying")
else:
self.debug(f"agent dying")
self.alive = False
try:
self.model.schedule.remove(self)
except KeyError:
pass
return time.NEVER
return time.Delay(time.INFINITY)
def step(self):
raise NotImplementedError("Agent must implement step method")
def _check_alive(self):
if not self.alive:
raise time.DeadAgent(self.unique_id)
def log(self, *message, level=logging.INFO, **kwargs):
if not self.logger.isEnabledFor(level):
return
@@ -266,407 +202,30 @@ class BaseAgent(MesaAgent, MutableMapping, metaclass=MetaAgent):
def __repr__(self):
return f"{self.__class__.__name__}({self.unique_id})"
def at(self, at):
return time.Delay(float(at) - self.now)
def prob(prob, random):
"""
A true/False uniform distribution with a given probability.
To be used like this:
.. code-block:: python
if prob(0.3):
do_something()
"""
r = random.random()
return r < prob
def calculate_distribution(network_agents=None, agent_class=None):
"""
Calculate the threshold values (thresholds for a uniform distribution)
of an agent distribution given the weights of each agent type.
The input has this form: ::
[
{'agent_class': 'agent_class_1',
'weight': 0.2,
'state': {
'id': 0
}
},
{'agent_class': 'agent_class_2',
'weight': 0.8,
'state': {
'id': 1
}
}
]
In this example, 20% of the nodes will be marked as type
'agent_class_1'.
"""
if network_agents:
network_agents = [
deepcopy(agent) for agent in network_agents if not hasattr(agent, "id")
]
elif agent_class:
network_agents = [{"agent_class": agent_class}]
else:
raise ValueError("Specify a distribution or a default agent type")
# Fix missing weights and incompatible types
for x in network_agents:
x["weight"] = float(x.get("weight", 1))
# Calculate the thresholds
total = sum(x["weight"] for x in network_agents)
acc = 0
for v in network_agents:
if "ids" in v:
continue
upper = acc + (v["weight"] / total)
v["threshold"] = [acc, upper]
acc = upper
return network_agents
def _serialize_type(agent_class, known_modules=[], **kwargs):
if isinstance(agent_class, str):
return agent_class
known_modules += ["soil.agents"]
return serialization.serialize(agent_class, known_modules=known_modules, **kwargs)[
1
] # Get the name of the class
def _deserialize_type(agent_class, known_modules=[]):
if not isinstance(agent_class, str):
return agent_class
known = known_modules + ["soil.agents", "soil.agents.custom"]
agent_class = serialization.deserializer(agent_class, known_modules=known)
return agent_class
class AgentView(Mapping, Set):
"""A lazy-loaded list of agents."""
__slots__ = ("_agents",)
def __init__(self, agents):
self._agents = agents
def __getstate__(self):
return {"_agents": self._agents}
def __setstate__(self, state):
self._agents = state["_agents"]
# Mapping methods
def __len__(self):
return len(self._agents)
def __iter__(self):
yield from self._agents.values()
def __getitem__(self, agent_id):
if isinstance(agent_id, slice):
raise ValueError(f"Slicing is not supported")
if agent_id in self._agents:
return self._agents[agent_id]
raise ValueError(f"Agent {agent_id} not found")
def filter(self, *args, **kwargs):
yield from filter_agents(self._agents, *args, **kwargs)
def one(self, *args, **kwargs):
return next(filter_agents(self._agents, *args, **kwargs))
def __call__(self, *args, **kwargs):
return list(self.filter(*args, **kwargs))
def __contains__(self, agent_id):
return agent_id in self._agents
def __str__(self):
return str(list(unique_id for unique_id in self.keys()))
def __repr__(self):
return f"{self.__class__.__name__}({self})"
def filter_agents(
agents: dict,
*id_args,
unique_id=None,
state_id=None,
agent_class=None,
ignore=None,
state=None,
limit=None,
**kwargs,
):
"""
Filter agents given as a dict, by the criteria given as arguments (e.g., certain type or state id).
"""
assert isinstance(agents, dict)
ids = []
if unique_id is not None:
if isinstance(unique_id, list):
ids += unique_id
else:
ids.append(unique_id)
if id_args:
ids += id_args
if ids:
f = (agents[aid] for aid in ids if aid in agents)
else:
f = agents.values()
if state_id is not None and not isinstance(state_id, (tuple, list)):
state_id = tuple([state_id])
if agent_class is not None:
agent_class = _deserialize_type(agent_class)
try:
agent_class = tuple(agent_class)
except TypeError:
agent_class = tuple([agent_class])
if ignore:
f = filter(lambda x: x not in ignore, f)
if state_id is not None:
f = filter(lambda agent: agent.get("state_id", None) in state_id, f)
if agent_class is not None:
f = filter(lambda agent: isinstance(agent, agent_class), f)
state = state or dict()
state.update(kwargs)
for k, v in state.items():
f = filter(lambda agent: getattr(agent, k, None) == v, f)
if limit is not None:
f = islice(f, limit)
yield from f
def from_config(
cfg: config.AgentConfig, random, topology: nx.Graph = None
) -> List[Dict[str, Any]]:
"""
This function turns an agentconfig into a list of individual "agent specifications", which are just a dictionary
with the parameters that the environment will use to construct each agent.
This function does NOT return a list of agents, mostly because some attributes to the agent are not known at the
time of calling this function, such as `unique_id`.
"""
default = cfg or config.AgentConfig()
if not isinstance(cfg, config.AgentConfig):
cfg = config.AgentConfig(**cfg)
agents = []
assigned_total = 0
assigned_network = 0
if cfg.fixed is not None:
agents, assigned_total, assigned_network = _from_fixed(
cfg.fixed, topology=cfg.topology, default=cfg
)
n = cfg.n
if cfg.distribution:
topo_size = len(topology) if topology else 0
networked = []
total = []
for d in cfg.distribution:
if d.strategy == config.Strategy.topology:
topo = d.topology if ("topology" in d.__fields_set__) else cfg.topology
if not topo:
raise ValueError(
'The "topology" strategy only works if the topology parameter is set to True'
)
if not topo_size:
raise ValueError(
f"Topology does not have enough free nodes to assign one to the agent"
)
networked.append(d)
if d.strategy == config.Strategy.total:
if not cfg.n:
raise ValueError(
'Cannot use the "total" strategy without providing the total number of agents'
)
total.append(d)
if networked:
new_agents = _from_distro(
networked,
n=topo_size - assigned_network,
topology=topo,
default=cfg,
random=random,
)
assigned_total += len(new_agents)
assigned_network += len(new_agents)
agents += new_agents
if total:
remaining = n - assigned_total
agents += _from_distro(total, n=remaining, default=cfg, random=random)
if assigned_network < topo_size:
utils.logger.warn(
f"The total number of agents does not match the total number of nodes in "
"every topology. This may be due to a definition error: assigned: "
f"{ assigned } total size: { topo_size }"
)
return agents
def _from_fixed(
lst: List[config.FixedAgentConfig],
topology: bool,
default: config.SingleAgentConfig,
) -> List[Dict[str, Any]]:
agents = []
counts_total = 0
counts_network = 0
for fixed in lst:
agent = {}
if default:
agent = default.state.copy()
agent.update(fixed.state)
cls = serialization.deserialize(
fixed.agent_class or (default and default.agent_class)
)
agent["agent_class"] = cls
topo = (
fixed.topology
if ("topology" in fixed.__fields_set__)
else topology or default.topology
)
if topo:
agent["topology"] = True
counts_network += 1
if not fixed.hidden:
counts_total += 1
agents.append(agent)
return agents, counts_total, counts_network
def _from_distro(
distro: List[config.AgentDistro],
n: int,
default: config.SingleAgentConfig,
random,
topology: str = None
) -> List[Dict[str, Any]]:
agents = []
if n is None:
if any(lambda dist: dist.n is None, distro):
raise ValueError(
"You must provide a total number of agents, or the number of each type"
)
n = sum(dist.n for dist in distro)
weights = list(dist.weight if dist.weight is not None else 1 for dist in distro)
minw = min(weights)
norm = list(weight / minw for weight in weights)
total = sum(norm)
chunk = n // total
# random.choices would be enough to get a weighted distribution. But it can vary a lot for smaller k
# So instead we calculate our own distribution to make sure the actual ratios are close to what we would expect
# Calculate how many times each has to appear
indices = list(
chain.from_iterable([idx] * int(n * chunk) for (idx, n) in enumerate(norm))
)
# Complete with random agents following the original weight distribution
if len(indices) < n:
indices += random.choices(
list(range(len(distro))),
weights=[d.weight for d in distro],
k=n - len(indices),
)
# Deserialize classes for efficiency
classes = list(
serialization.deserialize(i.agent_class or default.agent_class) for i in distro
)
# Add them in random order
random.shuffle(indices)
for idx in indices:
d = distro[idx]
agent = d.state.copy()
cls = classes[idx]
agent["agent_class"] = cls
if default:
agent.update(default.state)
topology = (
d.topology
if ("topology" in d.__fields_set__)
else topology or default.topology
)
if topology:
agent["topology"] = topology
agents.append(agent)
return agents
def delay(self, delay=1):
return time.Delay(delay)
from .network_agents import *
from .fsm import *
from .evented import *
from typing import Optional
from .view import *
class Agent(NetworkAgent, FSM, EventedAgent):
"""Default agent class, has both network and event capabilities"""
class Noop(EventedAgent, BaseAgent):
def step(self):
return
from ..environment import NetworkEnvironment
class Agent(FSM, EventedAgent, NetworkAgent):
"""Default agent class, has network, FSM and event capabilities"""
# Additional types of agents
from .BassModel import *
from .IndependentCascadeModel import *
from .SISaModel import *
from .CounterModel import *
try:
import scipy
from .Geo import Geo
except ImportError:
import sys
print("Could not load the Geo Agent, scipy is not installed", file=sys.stderr)
def custom(cls, **kwargs):
"""Create a new class from a template class and keyword arguments"""
return type(cls.__name__, (cls,), kwargs)

View File

@@ -1,77 +1,34 @@
from . import BaseAgent
from ..events import Message, Tell, Ask, TimedOut
from ..time import BaseCond
from .. import environment, events
from functools import partial
from collections import deque
from types import coroutine
class ReceivedOrTimeout(BaseCond):
def __init__(
self, agent, expiration=None, timeout=None, check=True, ignore=False, **kwargs
):
if expiration is None:
if timeout is not None:
expiration = agent.now + timeout
self.expiration = expiration
self.ignore = ignore
self.check = check
super().__init__(**kwargs)
def expired(self, time):
return self.expiration and self.expiration < time
def ready(self, agent, time):
return len(agent._inbox) or self.expired(time)
def return_value(self, agent):
if not self.ignore and self.expired(agent.now):
raise TimedOut("No messages received")
if self.check:
agent.check_messages()
return None
def schedule_next(self, time, delta, first=False):
if self._delta is not None:
delta = self._delta
return (time + delta, self)
def __repr__(self):
return f"ReceivedOrTimeout(expires={self.expiration})"
# from soilent import Scheduler
class EventedAgent(BaseAgent):
# scheduler_class = Scheduler
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._inbox = deque()
self._processed = 0
assert isinstance(self.model, environment.EventedEnvironment), "EventedAgent requires an EventedEnvironment"
self.model.register(self)
def on_receive(self, *args, **kwargs):
pass
def received(self, **kwargs):
return self.model.received(agent=self, **kwargs)
def received(self, *args, **kwargs):
return ReceivedOrTimeout(self, *args, **kwargs)
def tell(self, msg, **kwargs):
return self.model.tell(msg, recipient=self, **kwargs)
def tell(self, msg, sender=None):
self._inbox.append(Tell(timestamp=self.now, payload=msg, sender=sender))
def broadcast(self, msg, **kwargs):
return self.model.broadcast(msg, sender=self, **kwargs)
def ask(self, msg, timeout=None, **kwargs):
ask = Ask(timestamp=self.now, payload=msg, sender=self)
self._inbox.append(ask)
expiration = float("inf") if timeout is None else self.now + timeout
return ask.replied(expiration=expiration, **kwargs)
def ask(self, msg, **kwargs):
return self.model.ask(msg, recipient=self, **kwargs)
def check_messages(self):
changed = False
while self._inbox:
msg = self._inbox.popleft()
self._processed += 1
if msg.expired(self.now):
continue
changed = True
reply = self.on_receive(msg.payload, sender=msg.sender)
if isinstance(msg, Ask):
msg.reply = reply
return changed
def process_messages(self):
return self.model.process_messages(self.model.inbox_for(self))
Evented = EventedAgent

Some files were not shown because too many files have changed in this diff Show More