1
0
mirror of https://github.com/gsi-upm/soil synced 2025-09-15 20:52:22 +00:00

Compare commits

..

9 Commits

Author SHA1 Message Date
J. Fernando Sánchez
5559d37e57 version 0.20.4 2022-05-18 15:20:58 +02:00
J. Fernando Sánchez
2116fe6f38 Bug fixes and minor improvements 2022-05-12 16:14:47 +02:00
J. Fernando Sánchez
affeeb9643 Update examples 2022-04-04 16:47:58 +02:00
J. Fernando Sánchez
42ddc02318 CI: delay PyPI check 2022-03-07 14:35:07 +01:00
J. Fernando Sánchez
cab9a3440b Fix typo CI/CD 2022-03-07 13:57:25 +01:00
J. Fernando Sánchez
db505da49c Minor CI change 2022-03-07 13:35:02 +01:00
J. Fernando Sánchez
8eb8eb16eb Minor CI change 2022-03-07 12:51:22 +01:00
J. Fernando Sánchez
3fc5ca8c08 Fix requirements issue CI/CD 2022-03-07 12:46:01 +01:00
J. Fernando Sánchez
c02e6ea2e8 Fix die bug 2022-03-07 11:17:27 +01:00
15 changed files with 189 additions and 90 deletions

View File

@@ -1,9 +1,10 @@
stages: stages:
- test - test
- build - publish
- check_published
build: docker:
stage: build stage: publish
image: image:
name: gcr.io/kaniko-project/executor:debug name: gcr.io/kaniko-project/executor:debug
entrypoint: [""] entrypoint: [""]
@@ -16,13 +17,37 @@ build:
only: only:
- tags - tags
test: test:
except:
- tags # Avoid running tests for tags, because they are already run for the branch
tags: tags:
- docker - docker
image: python:3.7 image: python:3.7
stage: test stage: test
script: script:
- pip install -r requirements.txt -r test-requirements.txt
- python setup.py test - python setup.py test
push_pypi:
only:
- tags
tags:
- docker
image: python:3.7
stage: publish
script:
- echo $CI_COMMIT_TAG > soil/VERSION
- pip install twine
- python setup.py sdist bdist_wheel
- TWINE_PASSWORD=$PYPI_PASSWORD TWINE_USERNAME=$PYPI_USERNAME python -m twine upload dist/*
check_pypi:
only:
- tags
tags:
- docker
image: python:3.7
stage: check_published
script:
- pip install soil==$CI_COMMIT_TAG
# Allow PYPI to update its index before we try to install
when: delayed
start_in: 2 minutes

View File

@@ -3,6 +3,29 @@ 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). 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.20.4]
### Added
* Agents can now be given any kwargs, which will be used to set their state
* Environments have a default logger `self.logger` and a log method, just like agents
## [0.20.3]
### Fixed
* Default state values are now deepcopied again.
* Seeds for environments only concatenate the trial id (i.e., a number), to provide repeatable results.
* `Environment.run` now calls `Environment.step`, to allow for easy overloading of the environment step
### Removed
* Datacollectors are not being used for now.
* `time.TimedActivation.step` does not use an `until` parameter anymore.
### Changed
* Simulations now run right up to `until` (open interval)
* Time instants (`time.When`) don't need to be floats anymore. Now we can avoid precision issues with big numbers by using ints.
* Rabbits simulation is more idiomatic (using subclasses)
## [0.20.2]
### Fixed
* CI/CD testing issues
## [0.20.1]
### Fixed
* Agents would run another step after dying.
## [0.20.0] ## [0.20.0]
### Added ### Added
* Integration with MESA * Integration with MESA

View File

@@ -1 +1 @@
ipython==7.23 ipython==7.31.1

View File

@@ -17,7 +17,7 @@ class DumbViewer(FSM):
def neutral(self): def neutral(self):
if self['has_tv']: if self['has_tv']:
if prob(self.env['prob_tv_spread']): if prob(self.env['prob_tv_spread']):
self.set_state(self.infected) return self.infected
@state @state
def infected(self): def infected(self):
@@ -26,6 +26,12 @@ class DumbViewer(FSM):
neighbor.infect() neighbor.infect()
def infect(self): def infect(self):
'''
This is not a state. It is a function that other agents can use to try to
infect this agent. DumbViewer always gets infected, but other agents like
HerdViewer might not become infected right away
'''
self.set_state(self.infected) self.set_state(self.infected)
@@ -35,12 +41,13 @@ class HerdViewer(DumbViewer):
''' '''
def infect(self): def infect(self):
'''Notice again that this is NOT a state. See DumbViewer.infect for reference'''
infected = self.count_neighboring_agents(state_id=self.infected.id) infected = self.count_neighboring_agents(state_id=self.infected.id)
total = self.count_neighboring_agents() total = self.count_neighboring_agents()
prob_infect = self.env['prob_neighbor_spread'] * infected/total prob_infect = self.env['prob_neighbor_spread'] * infected/total
self.debug('prob_infect', prob_infect) self.debug('prob_infect', prob_infect)
if prob(prob_infect): if prob(prob_infect):
self.set_state(self.infected.id) self.set_state(self.infected)
class WiseViewer(HerdViewer): class WiseViewer(HerdViewer):
@@ -75,5 +82,5 @@ class WiseViewer(HerdViewer):
1.0) 1.0)
prob_cure = self.env['prob_neighbor_cure'] * (cured/infected) prob_cure = self.env['prob_neighbor_cure'] * (cured/infected)
if prob(prob_cure): if prob(prob_cure):
return self.cure() return self.cured
return self.set_state(super().infected) return self.set_state(super().infected)

View File

@@ -18,7 +18,9 @@ class MyAgent(agents.FSM):
@agents.default_state @agents.default_state
@agents.state @agents.state
def neutral(self): def neutral(self):
self.info('I am running') self.debug('I am running')
if agents.prob(0.2):
self.info('This runs 2/10 times on average')
s = Simulation(name='Programmatic', s = Simulation(name='Programmatic',
@@ -29,10 +31,10 @@ s = Simulation(name='Programmatic',
dry_run=True) dry_run=True)
# By default, logging will only print WARNING logs (and above).
# You need to choose a lower logging level to get INFO/DEBUG traces
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
envs = s.run() envs = s.run()
s.dump_yaml() # Uncomment this to output the simulation to a YAML file
# s.dump_yaml('simulation.yaml')
for env in envs:
env.dump_csv()

View File

@@ -12,8 +12,6 @@ class Genders(Enum):
class RabbitModel(FSM): class RabbitModel(FSM):
level = logging.INFO
defaults = { defaults = {
'age': 0, 'age': 0,
'gender': Genders.male.value, 'gender': Genders.male.value,
@@ -36,6 +34,17 @@ class RabbitModel(FSM):
if self['age'] >= self.sexual_maturity: if self['age'] >= self.sexual_maturity:
self.debug('I am fertile!') self.debug('I am fertile!')
return self.fertile return self.fertile
@state
def fertile(self):
raise Exception("Each subclass should define its fertile state")
@state
def dead(self):
self.info('Agent {} is dying'.format(self.id))
self.die()
class Male(RabbitModel):
@state @state
def fertile(self): def fertile(self):
@@ -47,20 +56,26 @@ class RabbitModel(FSM):
return return
# Males try to mate # Males try to mate
for f in self.get_agents(state_id=self.fertile.id, gender=Genders.female.value, limit_neighbors=False, limit=self.max_females): for f in self.get_agents(state_id=Female.fertile.id,
agent_type=Female,
limit_neighbors=False,
limit=self.max_females):
r = random() r = random()
if r < self['mating_prob']: if r < self['mating_prob']:
self.impregnate(f) self.impregnate(f)
break # Take a break break # Take a break
def impregnate(self, whom): def impregnate(self, whom):
if self['gender'] == Genders.female.value:
raise NotImplementedError('Females cannot impregnate')
whom['pregnancy'] = 0 whom['pregnancy'] = 0
whom['mate'] = self.id whom['mate'] = self.id
whom.set_state(whom.pregnant) whom.set_state(whom.pregnant)
self.debug('{} impregnating: {}. {}'.format(self.id, whom.id, whom.state)) self.debug('{} impregnating: {}. {}'.format(self.id, whom.id, whom.state))
class Female(RabbitModel):
@state
def fertile(self):
# Just wait for a Male
pass
@state @state
def pregnant(self): def pregnant(self):
self['age'] += 1 self['age'] += 1
@@ -90,11 +105,9 @@ class RabbitModel(FSM):
@state @state
def dead(self): def dead(self):
self.info('Agent {} is dying'.format(self.id)) super().dead()
if 'pregnancy' in self and self['pregnancy'] > -1: if 'pregnancy' in self and self['pregnancy'] > -1:
self.info('A mother has died carrying a baby!!') self.info('A mother has died carrying a baby!!')
self.die()
return
class RandomAccident(NetworkAgent): class RandomAccident(NetworkAgent):

View File

@@ -1,23 +1,21 @@
--- ---
load_module: rabbit_agents load_module: rabbit_agents
name: rabbits_example name: rabbits_example
max_time: 150 max_time: 100
interval: 1 interval: 1
seed: MySeed seed: MySeed
agent_type: RabbitModel agent_type: rabbit_agents.RabbitModel
environment_agents: environment_agents:
- agent_type: RandomAccident - agent_type: rabbit_agents.RandomAccident
environment_params: environment_params:
prob_death: 0.001 prob_death: 0.001
default_state: default_state:
mating_prob: 0.01 mating_prob: 0.1
topology: topology:
nodes: nodes:
- id: 1 - id: 1
state: agent_type: rabbit_agents.Male
gender: female
- id: 0 - id: 0
state: agent_type: rabbit_agents.Female
gender: male
directed: true directed: true
links: [] links: []

View File

@@ -4,20 +4,34 @@ Example of a fully programmatic simulation, without definition files.
''' '''
from soil import Simulation, agents from soil import Simulation, agents
from soil.time import Delta from soil.time import Delta
from networkx import Graph
from random import expovariate from random import expovariate
import logging import logging
class MyAgent(agents.FSM): class MyAgent(agents.FSM):
'''
An agent that first does a ping
'''
defaults = {'pong_counts': 2}
@agents.default_state @agents.default_state
@agents.state @agents.state
def neutral(self): def ping(self):
self.info('I am running') self.info('Ping')
return self.pong, Delta(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:
return self.die()
return None, Delta(expovariate(1/16)) return None, Delta(expovariate(1/16))
s = Simulation(name='Programmatic', s = Simulation(name='Programmatic',
network_agents=[{'agent_type': MyAgent, 'id': 0}], network_agents=[{'agent_type': MyAgent, 'id': 0}],
topology={'nodes': [{'id': 0}], 'links': []}, topology={'nodes': [{'id': 0}], 'links': []},

View File

@@ -1 +1 @@
0.20.0 0.20.4

View File

@@ -65,6 +65,10 @@ def main():
logger.info('Loading config file: {}'.format(args.file)) logger.info('Loading config file: {}'.format(args.file))
if args.pdb:
args.synchronous = True
try: try:
exporters = list(args.exporter or ['default', ]) exporters = list(args.exporter or ['default', ])
if args.csv: if args.csv:

View File

@@ -20,6 +20,10 @@ def as_node(agent):
IGNORED_FIELDS = ('model', 'logger') IGNORED_FIELDS = ('model', 'logger')
class DeadAgent(Exception):
pass
class BaseAgent(Agent): class BaseAgent(Agent):
""" """
A special Agent that keeps track of its state history. A special Agent that keeps track of its state history.
@@ -31,7 +35,9 @@ class BaseAgent(Agent):
unique_id, unique_id,
model, model,
name=None, name=None,
interval=None): interval=None,
**kwargs
):
# Check for REQUIRED arguments # Check for REQUIRED arguments
# Initialize agent parameters # Initialize agent parameters
if isinstance(unique_id, Agent): if isinstance(unique_id, Agent):
@@ -48,7 +54,8 @@ class BaseAgent(Agent):
if hasattr(self, 'level'): if hasattr(self, 'level'):
self.logger.setLevel(self.level) self.logger.setLevel(self.level)
for (k, v) in kwargs.items():
setattr(self, k, v)
# TODO: refactor to clean up mesa compatibility # TODO: refactor to clean up mesa compatibility
@property @property
@@ -129,13 +136,14 @@ class BaseAgent(Agent):
return None return None
def die(self, remove=False): def die(self, remove=False):
self.info(f'agent {self.unique_id} is dying')
self.alive = False self.alive = False
if remove: if remove:
self.remove_node(self.id) self.remove_node(self.id)
def step(self): def step(self):
if not self.alive: if not self.alive:
return time.When('inf') raise DeadAgent(self.unique_id)
return super().step() or time.Delta(self.interval) return super().step() or time.Delta(self.interval)
def log(self, message, *args, level=logging.INFO, **kwargs): def log(self, message, *args, level=logging.INFO, **kwargs):
@@ -300,7 +308,10 @@ class FSM(NetworkAgent, metaclass=MetaFSM):
def step(self): def step(self):
self.debug(f'Agent {self.unique_id} @ state {self.state_id}') self.debug(f'Agent {self.unique_id} @ state {self.state_id}')
try:
interval = super().step() interval = super().step()
except DeadAgent:
return time.When('inf')
if 'id' not in self.state: if 'id' not in self.state:
# if 'id' in self.state: # if 'id' in self.state:
# self.set_state(self.state['id']) # self.set_state(self.state['id'])

View File

@@ -5,6 +5,7 @@ import math
import random import random
import yaml import yaml
import tempfile import tempfile
import logging
import pandas as pd import pandas as pd
from time import time as current_time from time import time as current_time
from copy import deepcopy from copy import deepcopy
@@ -101,6 +102,8 @@ class Environment(Model):
environment_agents = agents._convert_agent_types(distro) environment_agents = agents._convert_agent_types(distro)
self.environment_agents = environment_agents self.environment_agents = environment_agents
self.logger = utils.logger.getChild(self.name)
@property @property
def now(self): def now(self):
if self.schedule: if self.schedule:
@@ -169,11 +172,12 @@ class Environment(Model):
if agent_type: if agent_type:
state = defstate state = defstate
a = agent_type(model=self, a = agent_type(model=self,
unique_id=agent_id) unique_id=agent_id
)
for (k, v) in getattr(a, 'defaults', {}).items(): for (k, v) in getattr(a, 'defaults', {}).items():
if not hasattr(a, k) or getattr(a, k) is None: if not hasattr(a, k) or getattr(a, k) is None:
setattr(a, k, v) setattr(a, k, deepcopy(v))
for (k, v) in state.items(): for (k, v) in state.items():
setattr(a, k, v) setattr(a, k, v)
@@ -197,17 +201,29 @@ class Environment(Model):
start = start or self.now start = start or self.now
return self.G.add_edge(agent1, agent2, **attrs) return self.G.add_edge(agent1, agent2, **attrs)
def log(self, message, *args, level=logging.INFO, **kwargs):
if not self.logger.isEnabledFor(level):
return
message = message + " ".join(str(i) for i in args)
message = " @{:>3}: {}".format(self.now, message)
for k, v in kwargs:
message += " {k}={v} ".format(k, v)
extra = {}
extra['now'] = self.now
extra['unique_id'] = self.name
return self.logger.log(level, message, extra=extra)
def step(self): def step(self):
super().step() super().step()
self.datacollector.collect(self)
self.schedule.step() self.schedule.step()
def run(self, until, *args, **kwargs): def run(self, until, *args, **kwargs):
self._save_state() self._save_state()
while self.schedule.next_time <= until and not math.isinf(self.schedule.next_time): while self.schedule.next_time < until:
self.schedule.step(until=until) self.step()
utils.logger.debug(f'Simulation step {self.schedule.time}/{until}. Next: {self.schedule.next_time}') utils.logger.debug(f'Simulation step {self.schedule.time}/{until}. Next: {self.schedule.next_time}')
self.schedule.time = until
self._history.flush_cache() self._history.flush_cache()
def _save_state(self, now=None): def _save_state(self, now=None):

View File

@@ -145,9 +145,7 @@ class Simulation:
def _run_sync_or_async(self, parallel=False, *args, **kwargs): def _run_sync_or_async(self, parallel=False, *args, **kwargs):
if parallel and not os.environ.get('SENPY_DEBUG', None): if parallel and not os.environ.get('SENPY_DEBUG', None):
p = Pool() p = Pool()
func = partial(self.run_trial_exceptions, func = lambda x: self.run_trial_exceptions(trial_id=x, *args, **kwargs)
*args,
**kwargs)
for i in p.imap_unordered(func, range(self.num_trials)): for i in p.imap_unordered(func, range(self.num_trials)):
if isinstance(i, Exception): if isinstance(i, Exception):
logger.error('Trial failed:\n\t%s', i.message) logger.error('Trial failed:\n\t%s', i.message)
@@ -155,7 +153,8 @@ class Simulation:
yield i yield i
else: else:
for i in range(self.num_trials): for i in range(self.num_trials):
yield self.run_trial(*args, yield self.run_trial(trial_id=i,
*args,
**kwargs) **kwargs)
def run_gen(self, *args, parallel=False, dry_run=False, def run_gen(self, *args, parallel=False, dry_run=False,
@@ -224,7 +223,7 @@ class Simulation:
'''Create an environment for a trial of the simulation''' '''Create an environment for a trial of the simulation'''
opts = self.environment_params.copy() opts = self.environment_params.copy()
opts.update({ opts.update({
'name': trial_id, 'name': '{}_trial_{}'.format(self.name, trial_id),
'topology': self.topology.copy(), 'topology': self.topology.copy(),
'network_params': self.network_params, 'network_params': self.network_params,
'seed': '{}_trial_{}'.format(self.seed, trial_id), 'seed': '{}_trial_{}'.format(self.seed, trial_id),
@@ -241,12 +240,11 @@ class Simulation:
env = self.environment_class(**opts) env = self.environment_class(**opts)
return env return env
def run_trial(self, until=None, log_level=logging.INFO, **opts): def run_trial(self, trial_id=0, until=None, log_level=logging.INFO, **opts):
""" """
Run a single trial of the simulation Run a single trial of the simulation
""" """
trial_id = '{}_trial_{}'.format(self.name, time.time()).replace('.', '-')
if log_level: if log_level:
logger.setLevel(log_level) logger.setLevel(log_level)
# Set-up trial environment and graph # Set-up trial environment and graph

View File

@@ -6,9 +6,11 @@ from .utils import logger
from mesa import Agent from mesa import Agent
INFINITY = float('inf')
class When: class When:
def __init__(self, time): def __init__(self, time):
self._time = float(time) self._time = time
def abs(self, time): def abs(self, time):
return self._time return self._time
@@ -40,48 +42,34 @@ class TimedActivation(BaseScheduler):
heappush(self._queue, (self.time, agent.unique_id)) heappush(self._queue, (self.time, agent.unique_id))
super().add(agent) super().add(agent)
def step(self, until: float =float('inf')) -> None: def step(self) -> None:
""" """
Executes agents in order, one at a time. After each step, Executes agents in order, one at a time. After each step,
an agent will signal when it wants to be scheduled next. an agent will signal when it wants to be scheduled next.
""" """
when = None if self.next_time == INFINITY:
agent_id = None return
unsched = []
until = until or float('inf') self.time = self.next_time
when = self.time
while self._queue and self._queue[0][0] == self.time:
(when, agent_id) = heappop(self._queue)
logger.debug(f'Stepping agent {agent_id}')
when = (self._agents[agent_id].step() or Delta(1)).abs(self.time)
if when < self.time:
raise Exception("Cannot schedule an agent for a time in the past ({} < {})".format(when, self.time))
heappush(self._queue, (when, agent_id))
self.steps += 1
if not self._queue: if not self._queue:
self.time = until self.time = INFINITY
self.next_time = float('inf') self.next_time = INFINITY
return return
(when, agent_id) = self._queue[0] self.next_time = self._queue[0][0]
if until and when > until:
self.time = until
self.next_time = when
return
self.time = when
next_time = float("inf")
while when == self.time:
heappop(self._queue)
logger.debug(f'Stepping agent {agent_id}')
when = (self._agents[agent_id].step() or Delta(1)).abs(self.time)
heappush(self._queue, (when, agent_id))
if when < next_time:
next_time = when
if not self._queue or self._queue[0][0] > self.time:
agent_id = None
break
else:
(when, agent_id) = self._queue[0]
if when and when < self.time:
raise Exception("Invalid scheduling time")
self.next_time = next_time
self.steps += 1

View File

@@ -127,7 +127,7 @@ class TestMain(TestCase):
env = s.run_simulation(dry_run=True)[0] env = s.run_simulation(dry_run=True)[0]
for agent in env.network_agents: for agent in env.network_agents:
last = 0 last = 0
assert len(agent[None, None]) == 11 assert len(agent[None, None]) == 10
for step, total in sorted(agent['total', None]): for step, total in sorted(agent['total', None]):
assert total == last + 2 assert total == last + 2
last = total last = total