diff --git a/CHANGELOG.md b/CHANGELOG.md index 3261376..7c317d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,17 @@ 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). -## [Unreleased] +## [0.13.8] ### Changed +* Moved TerroristNetworkModel to examples ### Added +* `get_agents` and `count_agents` methods now accept lists as inputs. They can be used to retrieve agents from node ids +* `subgraph` in BaseAgent +* `agents.select` method, to filter out agents +* `skip_test` property in yaml definitions, to force skipping some examples +* `agents.Geo`, with a search function based on postition +* `BaseAgent.ego_search` to get nodes from the ego network of a node +* `BaseAgent.degree` and `BaseAgent.betweenness` ### Fixed ## [0.13.7] @@ -16,4 +24,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * Agent logging uses the agent name. * FSM agents can now return a timeout in addition to a new state. e.g. `return self.idle, self.env.timeout(2)` will execute the *different_state* in 2 *units of time* (`t_step=now+2`). * Example of using timeouts in FSM (custom_timeouts) -* `network_agents` entries may include an `ids` entry. If set, it should be a list of node ids that should be assigned that agent type. This complements the previous behavior of setting agent type with `weights`. \ No newline at end of file +* `network_agents` entries may include an `ids` entry. If set, it should be a list of node ids that should be assigned that agent type. This complements the previous behavior of setting agent type with `weights`. diff --git a/MANIFEST.in b/MANIFEST.in index 4976979..58b304b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,7 @@ include requirements.txt include test-requirements.txt include README.rst -graft soil \ No newline at end of file +graft soil +global-exclude __pycache__ +global-exclude soil_output +global-exclude *.py[co] diff --git a/examples/newsspread/NewsSpread.yml b/examples/newsspread/NewsSpread.yml index 787a46b..b3bd7ba 100644 --- a/examples/newsspread/NewsSpread.yml +++ b/examples/newsspread/NewsSpread.yml @@ -6,7 +6,7 @@ environment_params: prob_neighbor_spread: 0.0 prob_tv_spread: 0.01 interval: 1 -max_time: 30 +max_time: 300 name: Sim_all_dumb network_agents: - agent_type: DumbViewer @@ -30,7 +30,7 @@ environment_params: prob_neighbor_spread: 0.0 prob_tv_spread: 0.01 interval: 1 -max_time: 30 +max_time: 300 name: Sim_half_herd network_agents: - agent_type: DumbViewer @@ -62,7 +62,7 @@ environment_params: prob_neighbor_spread: 0.0 prob_tv_spread: 0.01 interval: 1 -max_time: 30 +max_time: 300 name: Sim_all_herd network_agents: - agent_type: HerdViewer @@ -89,7 +89,7 @@ environment_params: prob_tv_spread: 0.01 prob_neighbor_cure: 0.1 interval: 1 -max_time: 30 +max_time: 300 name: Sim_wise_herd network_agents: - agent_type: HerdViewer @@ -115,7 +115,7 @@ environment_params: prob_tv_spread: 0.01 prob_neighbor_cure: 0.1 interval: 1 -max_time: 30 +max_time: 300 name: Sim_all_wise network_agents: - agent_type: WiseViewer diff --git a/examples/terrorism/TerroristNetworkModel.py b/examples/terrorism/TerroristNetworkModel.py new file mode 100644 index 0000000..05709a1 --- /dev/null +++ b/examples/terrorism/TerroristNetworkModel.py @@ -0,0 +1,208 @@ +import random +import networkx as nx +from soil.agents import Geo, NetworkAgent, FSM, state, default_state +from soil import Environment + + +class TerroristSpreadModel(FSM, Geo): + """ + Settings: + information_spread_intensity + + terrorist_additional_influence + + min_vulnerability (optional else zero) + + max_vulnerability + + prob_interaction + """ + + def __init__(self, environment=None, agent_id=0, state=()): + super().__init__(environment=environment, agent_id=agent_id, state=state) + + self.information_spread_intensity = environment.environment_params['information_spread_intensity'] + self.terrorist_additional_influence = environment.environment_params['terrorist_additional_influence'] + self.prob_interaction = environment.environment_params['prob_interaction'] + + if self['id'] == self.civilian.id: # Civilian + self.mean_belief = random.uniform(0.00, 0.5) + elif self['id'] == self.terrorist.id: # Terrorist + self.mean_belief = random.uniform(0.8, 1.00) + elif self['id'] == self.leader.id: # Leader + self.mean_belief = 1.00 + else: + raise Exception('Invalid state id: {}'.format(self['id'])) + + if 'min_vulnerability' in environment.environment_params: + self.vulnerability = random.uniform( environment.environment_params['min_vulnerability'], environment.environment_params['max_vulnerability'] ) + else : + self.vulnerability = random.uniform( 0, environment.environment_params['max_vulnerability'] ) + + + @state + def civilian(self): + neighbours = list(self.get_neighboring_agents(agent_type=TerroristSpreadModel)) + if len(neighbours) > 0: + # Only interact with some of the neighbors + interactions = list(n for n in neighbours if random.random() <= self.prob_interaction) + influence = sum( self.degree(i) for i in interactions ) + mean_belief = sum( i.mean_belief * self.degree(i) / influence for i in interactions ) + mean_belief = mean_belief * self.information_spread_intensity + self.mean_belief * ( 1 - self.information_spread_intensity ) + self.mean_belief = mean_belief * self.vulnerability + self.mean_belief * ( 1 - self.vulnerability ) + + if self.mean_belief >= 0.8: + return self.terrorist + + @state + def leader(self): + self.mean_belief = self.mean_belief ** ( 1 - self.terrorist_additional_influence ) + for neighbour in self.get_neighboring_agents(state_id=[self.terrorist.id, self.leader.id]): + if self.betweenness(neighbour) > self.betweenness(self): + return self.terrorist + + @state + def terrorist(self): + neighbours = self.get_agents(state_id=[self.terrorist.id, self.leader.id], + agent_type=TerroristSpreadModel, + limit_neighbors=True) + if len(neighbours) > 0: + influence = sum( self.degree(n) for n in neighbours ) + mean_belief = sum( n.mean_belief * self.degree(n) / influence for n in neighbours ) + mean_belief = mean_belief * self.vulnerability + self.mean_belief * ( 1 - self.vulnerability ) + self.mean_belief = self.mean_belief ** ( 1 - self.terrorist_additional_influence ) + + # Check if there are any leaders in the group + leaders = list(filter(lambda x: x.state.id == self.leader.id, neighbours)) + if not leaders: + # Check if this is the potential leader + # Stop once it's found. Otherwise, set self as leader + for neighbour in neighbours: + if self.betweenness(self) < self.betweenness(neighbour): + return + return self.leader + + +class TrainingAreaModel(FSM, Geo): + """ + Settings: + training_influence + + min_vulnerability + + Requires TerroristSpreadModel. + """ + + def __init__(self, environment=None, agent_id=0, state=()): + super().__init__(environment=environment, agent_id=agent_id, state=state) + self.training_influence = environment.environment_params['training_influence'] + if 'min_vulnerability' in environment.environment_params: + self.min_vulnerability = environment.environment_params['min_vulnerability'] + else: self.min_vulnerability = 0 + + @default_state + @state + def terrorist(self): + for neighbour in self.get_neighboring_agents(agent_type=TerroristSpreadModel): + if neighbour.vulnerability > self.min_vulnerability: + neighbour.vulnerability = neighbour.vulnerability ** ( 1 - self.training_influence ) + + +class HavenModel(FSM, Geo): + """ + Settings: + haven_influence + + min_vulnerability + + max_vulnerability + + Requires TerroristSpreadModel. + """ + + def __init__(self, environment=None, agent_id=0, state=()): + super().__init__(environment=environment, agent_id=agent_id, state=state) + self.haven_influence = environment.environment_params['haven_influence'] + if 'min_vulnerability' in environment.environment_params: + self.min_vulnerability = environment.environment_params['min_vulnerability'] + else: self.min_vulnerability = 0 + self.max_vulnerability = environment.environment_params['max_vulnerability'] + + def get_occupants(self, **kwargs): + return self.get_neighboring_agents(agent_type=TerroristSpreadModel, **kwargs) + + @state + def civilian(self): + civilians = self.get_occupants(state_id=self.civilian.id) + if not civilians: + return self.terrorist + + for neighbour in self.get_occupants(): + if neighbour.vulnerability > self.min_vulnerability: + neighbour.vulnerability = neighbour.vulnerability * ( 1 - self.haven_influence ) + return self.civilian + + @state + def terrorist(self): + for neighbour in self.get_occupants(): + if neighbour.vulnerability < self.max_vulnerability: + neighbour.vulnerability = neighbour.vulnerability ** ( 1 - self.haven_influence ) + return self.terrorist + + +class TerroristNetworkModel(TerroristSpreadModel): + """ + Settings: + sphere_influence + + vision_range + + weight_social_distance + + weight_link_distance + """ + + def __init__(self, environment=None, agent_id=0, state=()): + super().__init__(environment=environment, agent_id=agent_id, state=state) + + self.vision_range = environment.environment_params['vision_range'] + self.sphere_influence = environment.environment_params['sphere_influence'] + self.weight_social_distance = environment.environment_params['weight_social_distance'] + self.weight_link_distance = environment.environment_params['weight_link_distance'] + + @state + def terrorist(self): + self.update_relationships() + return super().terrorist() + + @state + def leader(self): + self.update_relationships() + return super().leader() + + def update_relationships(self): + if self.count_neighboring_agents(state_id=self.civilian.id) == 0: + close_ups = set(self.geo_search(radius=self.vision_range, agent_type=TerroristNetworkModel)) + step_neighbours = set(self.ego_search(self.sphere_influence, agent_type=TerroristNetworkModel, center=False)) + neighbours = set(agent.id for agent in self.get_neighboring_agents(agent_type=TerroristNetworkModel)) + search = (close_ups | step_neighbours) - neighbours + for agent in self.get_agents(search): + social_distance = 1 / self.shortest_path_length(agent.id) + spatial_proximity = ( 1 - self.get_distance(agent.id) ) + prob_new_interaction = self.weight_social_distance * social_distance + self.weight_link_distance * spatial_proximity + if agent['id'] == agent.civilian.id and random.random() < prob_new_interaction: + self.add_edge(agent) + break + + def get_distance(self, target): + source_x, source_y = nx.get_node_attributes(self.global_topology, 'pos')[self.id] + target_x, target_y = nx.get_node_attributes(self.global_topology, 'pos')[target] + dx = abs( source_x - target_x ) + dy = abs( source_y - target_y ) + return ( dx ** 2 + dy ** 2 ) ** ( 1 / 2 ) + + def shortest_path_length(self, target): + try: + return nx.shortest_path_length(self.global_topology, self.id, target) + except nx.NetworkXNoPath: + return float('inf') diff --git a/soil/web/TerroristNetworkModel.yml b/examples/terrorism/TerroristNetworkModel.yml similarity index 95% rename from soil/web/TerroristNetworkModel.yml rename to examples/terrorism/TerroristNetworkModel.yml index b42b06d..401b77d 100644 --- a/soil/web/TerroristNetworkModel.yml +++ b/examples/terrorism/TerroristNetworkModel.yml @@ -60,3 +60,4 @@ visualization_params: background_image: 'map_4800x2860.jpg' background_opacity: '0.9' background_filter_color: 'blue' +skip_test: true # This simulation takes too long for automated tests. diff --git a/requirements.txt b/requirements.txt index a0f4130..6797dad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ numpy matplotlib pyyaml pandas +scipy diff --git a/soil/VERSION b/soil/VERSION index 5daaa7b..94cade1 100644 --- a/soil/VERSION +++ b/soil/VERSION @@ -1 +1 @@ -0.13.7 +0.13.8 diff --git a/soil/agents/CounterModel.py b/soil/agents/CounterModel.py index 8b25886..cc36726 100644 --- a/soil/agents/CounterModel.py +++ b/soil/agents/CounterModel.py @@ -22,11 +22,17 @@ class AggregatedCounter(BaseAgent): in each step and adds it to its state. """ + defaults = { + 'times': 0, + 'neighbors': 0, + 'total': 0 + } + def step(self): # Outside effects - total = len(list(self.get_all_agents())) + self['times'] += 1 neighbors = len(list(self.get_neighboring_agents())) - self['times'] = self.get('times', 0) + 1 - self['neighbors'] = self.get('neighbors', 0) + neighbors - self['total'] = total = self.get('total', 0) + total + self['neighbors'] += neighbors + total = len(list(self.get_all_agents())) + self['total'] += total self.debug('Running for step: {}. Total: {}'.format(self.now, total)) diff --git a/soil/agents/__init__.py b/soil/agents/__init__.py index 40176f5..69799f5 100644 --- a/soil/agents/__init__.py +++ b/soil/agents/__init__.py @@ -10,6 +10,7 @@ import logging from collections import OrderedDict from copy import deepcopy from functools import partial +from scipy.spatial import cKDTree as KDTree import json from functools import wraps @@ -17,6 +18,12 @@ from functools import wraps from .. import utils, history +def as_node(agent): + if isinstance(agent, BaseAgent): + return agent.id + return agent + + class BaseAgent(nxsim.BaseAgent): """ A special simpy BaseAgent that keeps track of its state history. @@ -46,8 +53,7 @@ class BaseAgent(nxsim.BaseAgent): if not hasattr(self, 'level'): self.level = logging.DEBUG - self.logger = logging.getLogger('{}.{}'.format(self.env.name, - self.id)) + self.logger = logging.getLogger(self.env.name) self.logger.setLevel(self.level) # initialize every time an instance of the agent is created @@ -134,43 +140,21 @@ class BaseAgent(nxsim.BaseAgent): def step(self): pass - def count_agents(self, state_id=None, limit_neighbors=False): + def count_agents(self, **kwargs): + return len(list(self.get_agents(**kwargs))) + + def count_neighboring_agents(self, state_id=None, **kwargs): + return len(super().get_neighboring_agents(state_id=state_id, **kwargs)) + + def get_neighboring_agents(self, state_id=None, **kwargs): + return self.get_agents(limit_neighbors=True, state_id=state_id, **kwargs) + + def get_agents(self, agents=None, limit_neighbors=False, **kwargs): if limit_neighbors: - agents = self.global_topology.neighbors(self.id) + agents = super().get_agents(limit_neighbors=limit_neighbors) else: - agents = self.global_topology.nodes() - count = 0 - for agent in agents: - if state_id and state_id != self.global_topology.node[agent]['agent']['id']: - continue - count += 1 - return count - - def count_neighboring_agents(self, state_id=None): - return len(super().get_agents(state_id, limit_neighbors=True)) - - def get_agents(self, state_id=None, agent_type=None, limit_neighbors=False, iterator=False, **kwargs): - agents = self.env.agents - if limit_neighbors: - agents = super().get_agents(state_id, limit_neighbors) - - def matches_all(agent): - if state_id is not None: - if agent.state.get('id', None) != state_id: - return False - if agent_type is not None: - if type(agent) != agent_type: - return False - state = agent.state - for k, v in kwargs.items(): - if state.get(k, None) != v: - return False - return True - - f = filter(matches_all, agents) - if iterator: - return f - return list(f) + agents = self.env.get_agents(agents) + return select(agents, **kwargs) def log(self, message, *args, level=logging.INFO, **kwargs): message = message + " ".join(str(i) for i in args) @@ -208,31 +192,76 @@ class BaseAgent(nxsim.BaseAgent): self._state = state['_state'] self.env = state['environment'] + def add_edge(self, node1, node2, **attrs): + node1 = as_node(node1) + node2 = as_node(node2) -def state(func): - ''' - A state function should return either a state id, or a tuple (state_id, when) - The default value for state_id is the current state id. - The default value for when is the interval defined in the nevironment. - ''' + for n in [node1, node2]: + if n not in self.global_topology.nodes(data=False): + raise ValueError('"{}" not in the graph'.format(n)) + return self.global_topology.add_edge(node1, node2, **attrs) - @wraps(func) - def func_wrapper(self): - next_state = func(self) - when = None - if next_state is None: + def subgraph(self, center=True, **kwargs): + include = [self] if center else [] + return self.global_topology.subgraph(n.id for n in self.get_agents(**kwargs)+include) + + +class NetworkAgent(BaseAgent): + + def add_edge(self, other, **kwargs): + return super(NetworkAgent, self).add_edge(node1=self.id, node2=other, **kwargs) + + def ego_search(self, steps=1, center=False, node=None, **kwargs): + '''Get a list of nodes in the ego network of *node* of radius *steps*''' + node = as_node(node if node is not None else self) + G = self.subgraph(**kwargs) + return nx.ego_graph(G, node, center=center, radius=steps).nodes() + + def degree(self, node, force=False): + node = as_node(node) + if force or (not hasattr(self.env, '_degree')) or getattr(self.env, '_last_step', 0) < self.now: + self.env._degree = nx.degree_centrality(self.global_topology) + self.env._last_step = self.now + return self.env._degree[node] + + def betweenness(self, node, force=False): + node = as_node(node) + if force or (not hasattr(self.env, '_betweenness')) or getattr(self.env, '_last_step', 0) < self.now: + self.env._betweenness = nx.betweenness_centrality(self.global_topology) + self.env._last_step = self.now + return self.env._betweenness[node] + + +def state(name=None): + def decorator(func, name=None): + ''' + A state function should return either a state id, or a tuple (state_id, when) + The default value for state_id is the current state id. + The default value for when is the interval defined in the environment. + ''' + + @wraps(func) + def func_wrapper(self): + next_state = func(self) + when = None + if next_state is None: + return when + try: + next_state, when = next_state + except (ValueError, TypeError): + pass + if next_state: + self.set_state(next_state) return when - try: - next_state, when = next_state - except (ValueError, TypeError): - pass - if next_state: - self.set_state(next_state) - return when - func_wrapper.id = func.__name__ - func_wrapper.is_default = False - return func_wrapper + func_wrapper.id = name or func.__name__ + func_wrapper.is_default = False + return func_wrapper + + if callable(name): + return decorator(name) + else: + return partial(decorator, name=name) def default_state(func): @@ -340,7 +369,7 @@ def calculate_distribution(network_agents=None, elif agent_type: network_agents = [{'agent_type': agent_type}] else: - return [] + raise ValueError('Specify a distribution or a default agent type') # Calculate the thresholds total = sum(x.get('weight', 1) for x in network_agents) @@ -427,6 +456,58 @@ def _agent_from_distribution(distribution, value=-1, agent_id=None): raise Exception('Distribution for value {} not found in: {}'.format(value, distribution)) +class Geo(NetworkAgent): + '''In this type of network, nodes have a "pos" attribute.''' + + def geo_search(self, radius, node=None, center=False, **kwargs): + '''Get a list of nodes whose coordinates are closer than *radius* to *node*.''' + node = as_node(node if node is not None else self) + + G = self.subgraph(**kwargs) + + pos = nx.get_node_attributes(G, 'pos') + if not pos: + return [] + nodes, coords = list(zip(*pos.items())) + kdtree = KDTree(coords) # Cannot provide generator. + indices = kdtree.query_ball_point(pos[node], radius) + return [nodes[i] for i in indices if center or (nodes[i] != node)] + + +def select(agents, state_id=None, agent_type=None, ignore=None, iterator=False, **kwargs): + + if state_id is not None: + try: + state_id = tuple(state_id) + except TypeError: + state_id = tuple([state_id]) + if agent_type is not None: + try: + agent_type = tuple(agent_type) + except TypeError: + agent_type = tuple([agent_type]) + + def matches_all(agent): + if state_id is not None: + if agent.state.get('id', None) not in state_id: + return False + if agent_type is not None: + if not isinstance(agent, agent_type): + return False + state = agent.state + for k, v in kwargs.items(): + if state.get(k, None) != v: + return False + return True + + f = filter(matches_all, agents) + if ignore: + f = filter(lambda x: x not in ignore, f) + if iterator: + return f + return list(f) + + from .BassModel import * from .BigMarketModel import * from .IndependentCascadeModel import * diff --git a/soil/environment.py b/soil/environment.py index 4ff5928..e7f092e 100644 --- a/soil/environment.py +++ b/soil/environment.py @@ -102,8 +102,7 @@ class Environment(nxsim.NetworkEnvironment): @network_agents.setter def network_agents(self, network_agents): - if not network_agents: - return + self._network_agents = network_agents for ix in self.G.nodes(): self.init_agent(ix, agent_distribution=network_agents) @@ -124,6 +123,9 @@ class Environment(nxsim.NetworkEnvironment): agent_type = agents.deserialize_type(agent_type) elif agent_distribution: agent_type, state = agents._agent_from_distribution(agent_distribution, agent_id=agent_id) + else: + utils.logger.debug('Skipping node {}'.format(agent_id)) + return return self.set_agent(agent_id, agent_type, state) def set_agent(self, agent_id, agent_type, state=None): @@ -149,12 +151,13 @@ class Environment(nxsim.NetworkEnvironment): a['visible'] = True return a - def add_edge(self, agent1, agent2, attrs=None): + def add_edge(self, agent1, agent2, start=None, **attrs): if hasattr(agent1, 'id'): agent1 = agent1.id if hasattr(agent2, 'id'): agent2 = agent2.id - return self.G.add_edge(agent1, agent2) + start = start or self.now + return self.G.add_edge(agent1, agent2, **attrs) def run(self, *args, **kwargs): self._save_state() @@ -231,8 +234,10 @@ class Environment(nxsim.NetworkEnvironment): def get_agent(self, agent_id): return self.G.node[agent_id]['agent'] - def get_agents(self): - return list(self.agents) + def get_agents(self, nodes=None): + if nodes is None: + return list(self.agents) + return [self.G.node[i]['agent'] for i in nodes] def dump_csv(self, dir_path=None): csv_name = os.path.join(self.get_path(dir_path), diff --git a/soil/utils.py b/soil/utils.py index 6aec6b8..eae9a2f 100644 --- a/soil/utils.py +++ b/soil/utils.py @@ -147,7 +147,7 @@ def deserializer(type_, known_modules=[]): module = importlib.import_module(modname) cls = getattr(module, tname) return getattr(cls, 'deserialize', cls) - except (ImportError, AttributeError) as ex: + except (ModuleNotFoundError, AttributeError) as ex: errors.append((modname, tname, ex)) raise Exception('Could not find type {}. Tried: {}'.format(type_, errors)) diff --git a/soil/web/TerroristNetworkModel.py b/soil/web/TerroristNetworkModel.py deleted file mode 100644 index d81ff7f..0000000 --- a/soil/web/TerroristNetworkModel.py +++ /dev/null @@ -1,255 +0,0 @@ -import random -import networkx as nx -from soil.agents import BaseAgent, FSM, state, default_state -from scipy.spatial import cKDTree as KDTree - -global betweenness_centrality_global -global degree_centrality_global - -betweenness_centrality_global = None -degree_centrality_global = None - -class TerroristSpreadModel(FSM): - """ - Settings: - information_spread_intensity - - terrorist_additional_influence - - min_vulnerability (optional else zero) - - max_vulnerability - - prob_interaction - """ - - def __init__(self, environment=None, agent_id=0, state=()): - super().__init__(environment=environment, agent_id=agent_id, state=state) - - global betweenness_centrality_global - global degree_centrality_global - - if betweenness_centrality_global == None: - betweenness_centrality_global = nx.betweenness_centrality(self.global_topology) - if degree_centrality_global == None: - degree_centrality_global = nx.degree_centrality(self.global_topology) - - self.information_spread_intensity = environment.environment_params['information_spread_intensity'] - self.terrorist_additional_influence = environment.environment_params['terrorist_additional_influence'] - self.prob_interaction = environment.environment_params['prob_interaction'] - - if self['id'] == self.civilian.id: # Civilian - self.initial_belief = random.uniform(0.00, 0.5) - elif self['id'] == self.terrorist.id: # Terrorist - self.initial_belief = random.uniform(0.8, 1.00) - elif self['id'] == self.leader.id: # Leader - self.initial_belief = 1.00 - else: - raise Exception('Invalid state id: {}'.format(self['id'])) - - if 'min_vulnerability' in environment.environment_params: - self.vulnerability = random.uniform( environment.environment_params['min_vulnerability'], environment.environment_params['max_vulnerability'] ) - else : - self.vulnerability = random.uniform( 0, environment.environment_params['max_vulnerability'] ) - - self.mean_belief = self.initial_belief - self.betweenness_centrality = betweenness_centrality_global[self.id] - self.degree_centrality = degree_centrality_global[self.id] - - # self.state['radicalism'] = self.mean_belief - - def count_neighboring_agents(self, state_id=None): - if isinstance(state_id, list): - return len(self.get_neighboring_agents(state_id)) - else: - return len(super().get_agents(state_id, limit_neighbors=True)) - - def get_neighboring_agents(self, state_id=None): - if isinstance(state_id, list): - _list = [] - for i in state_id: - _list += super().get_agents(i, limit_neighbors=True) - return [ neighbour for neighbour in _list if isinstance(neighbour, TerroristSpreadModel) ] - else: - _list = super().get_agents(state_id, limit_neighbors=True) - return [ neighbour for neighbour in _list if isinstance(neighbour, TerroristSpreadModel) ] - - @state - def civilian(self): - if self.count_neighboring_agents() > 0: - neighbours = [] - for neighbour in self.get_neighboring_agents(): - if random.random() < self.prob_interaction: - neighbours.append(neighbour) - influence = sum( neighbour.degree_centrality for neighbour in neighbours ) - mean_belief = sum( neighbour.mean_belief * neighbour.degree_centrality / influence for neighbour in neighbours ) - self.initial_belief = self.mean_belief - mean_belief = mean_belief * self.information_spread_intensity + self.initial_belief * ( 1 - self.information_spread_intensity ) - self.mean_belief = mean_belief * self.vulnerability + self.initial_belief * ( 1 - self.vulnerability ) - - if self.mean_belief >= 0.8: - return self.terrorist - - @state - def leader(self): - self.mean_belief = self.mean_belief ** ( 1 - self.terrorist_additional_influence ) - if self.count_neighboring_agents(state_id=[self.terrorist.id, self.leader.id]) > 0: - for neighbour in self.get_neighboring_agents(state_id=[self.terrorist.id, self.leader.id]): - if neighbour.betweenness_centrality > self.betweenness_centrality: - return self.terrorist - - @state - def terrorist(self): - if self.count_neighboring_agents(state_id=[self.terrorist.id, self.leader.id]) > 0: - neighbours = self.get_neighboring_agents(state_id=[self.terrorist.id, self.leader.id]) - influence = sum( neighbour.degree_centrality for neighbour in neighbours ) - mean_belief = sum( neighbour.mean_belief * neighbour.degree_centrality / influence for neighbour in neighbours ) - self.initial_belief = self.mean_belief - self.mean_belief = mean_belief * self.vulnerability + self.initial_belief * ( 1 - self.vulnerability ) - self.mean_belief = self.mean_belief ** ( 1 - self.terrorist_additional_influence ) - - if self.count_neighboring_agents(state_id=self.leader.id) == 0 and self.count_neighboring_agents(state_id=self.terrorist.id) > 0: - max_betweenness_centrality = self - for neighbour in self.get_neighboring_agents(state_id=self.terrorist.id): - if neighbour.betweenness_centrality > max_betweenness_centrality.betweenness_centrality: - max_betweenness_centrality = neighbour - if max_betweenness_centrality == self: - return self.leader - - def add_edge(self, G, source, target): - G.add_edge(source.id, target.id, start=self.env._now) - - def link_search(self, G, node, radius): - pos = nx.get_node_attributes(G, 'pos') - nodes, coords = list(zip(*pos.items())) - kdtree = KDTree(coords) # Cannot provide generator. - edge_indexes = kdtree.query_pairs(radius, 2) - _list = [ edge[int(not edge.index(node))] for edge in edge_indexes if node in edge ] - return [ G.nodes()[index]['agent'] for index in _list ] - - def social_search(self, G, node, steps): - nodes = list(nx.ego_graph(G, node, radius=steps).nodes()) - nodes.remove(node) - return [ G.nodes()[index]['agent'] for index in nodes ] - - -class TrainingAreaModel(FSM): - """ - Settings: - training_influence - - min_vulnerability - - Requires TerroristSpreadModel. - """ - - def __init__(self, environment=None, agent_id=0, state=()): - super().__init__(environment=environment, agent_id=agent_id, state=state) - self.training_influence = environment.environment_params['training_influence'] - if 'min_vulnerability' in environment.environment_params: - self.min_vulnerability = environment.environment_params['min_vulnerability'] - else: self.min_vulnerability = 0 - - @default_state - @state - def terrorist(self): - for neighbour in self.get_neighboring_agents(): - if isinstance(neighbour, TerroristSpreadModel) and neighbour.vulnerability > self.min_vulnerability: - neighbour.vulnerability = neighbour.vulnerability ** ( 1 - self.training_influence ) - - -class HavenModel(FSM): - """ - Settings: - haven_influence - - min_vulnerability - - max_vulnerability - - Requires TerroristSpreadModel. - """ - - def __init__(self, environment=None, agent_id=0, state=()): - super().__init__(environment=environment, agent_id=agent_id, state=state) - self.haven_influence = environment.environment_params['haven_influence'] - if 'min_vulnerability' in environment.environment_params: - self.min_vulnerability = environment.environment_params['min_vulnerability'] - else: self.min_vulnerability = 0 - self.max_vulnerability = environment.environment_params['max_vulnerability'] - - @state - def civilian(self): - for neighbour_agent in self.get_neighboring_agents(): - if isinstance(neighbour_agent, TerroristSpreadModel) and neighbour_agent['id'] == neighbour_agent.civilian.id: - for neighbour in self.get_neighboring_agents(): - if isinstance(neighbour, TerroristSpreadModel) and neighbour.vulnerability > self.min_vulnerability: - neighbour.vulnerability = neighbour.vulnerability * ( 1 - self.haven_influence ) - return self.civilian - return self.terrorist - - @state - def terrorist(self): - for neighbour in self.get_neighboring_agents(): - if isinstance(neighbour, TerroristSpreadModel) and neighbour.vulnerability < self.max_vulnerability: - neighbour.vulnerability = neighbour.vulnerability ** ( 1 - self.haven_influence ) - return self.terrorist - - -class TerroristNetworkModel(TerroristSpreadModel): - """ - Settings: - sphere_influence - - vision_range - - weight_social_distance - - weight_link_distance - """ - - def __init__(self, environment=None, agent_id=0, state=()): - super().__init__(environment=environment, agent_id=agent_id, state=state) - - self.vision_range = environment.environment_params['vision_range'] - self.sphere_influence = environment.environment_params['sphere_influence'] - self.weight_social_distance = environment.environment_params['weight_social_distance'] - self.weight_link_distance = environment.environment_params['weight_link_distance'] - - @state - def terrorist(self): - self.update_relationships() - return super().terrorist() - - @state - def leader(self): - self.update_relationships() - return super().leader() - - def update_relationships(self): - if self.count_neighboring_agents(state_id=self.civilian.id) == 0: - close_ups = self.link_search(self.global_topology, self.id, self.vision_range) - step_neighbours = self.social_search(self.global_topology, self.id, self.sphere_influence) - search = list(set(close_ups).union(step_neighbours)) - neighbours = self.get_neighboring_agents() - search = [item for item in search if not item in neighbours and isinstance(item, TerroristNetworkModel)] - for agent in search: - social_distance = 1 / self.shortest_path_length(self.global_topology, self.id, agent.id) - spatial_proximity = ( 1 - self.get_distance(self.global_topology, self.id, agent.id) ) - prob_new_interaction = self.weight_social_distance * social_distance + self.weight_link_distance * spatial_proximity - if agent['id'] == agent.civilian.id and random.random() < prob_new_interaction: - self.add_edge(self.global_topology, self, agent) - break - - def get_distance(self, G, source, target): - source_x, source_y = nx.get_node_attributes(G, 'pos')[source] - target_x, target_y = nx.get_node_attributes(G, 'pos')[target] - dx = abs( source_x - target_x ) - dy = abs( source_y - target_y ) - return ( dx ** 2 + dy ** 2 ) ** ( 1 / 2 ) - - def shortest_path_length(self, G, source, target): - try: - return nx.shortest_path_length(G, source, target) - except nx.NetworkXNoPath: - return float('inf') diff --git a/tests/test_examples.py b/tests/test_examples.py index 671b6b0..2936808 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -7,6 +7,8 @@ from soil import utils, simulation ROOT = os.path.abspath(os.path.dirname(__file__)) EXAMPLES = join(ROOT, '..', 'examples') +FORCE_TESTS = os.environ.get('FORCE_TESTS', '') + class TestExamples(TestCase): pass @@ -19,7 +21,10 @@ def make_example_test(path, config): s = simulation.from_config(config) iterations = s.max_time * s.num_trials if iterations > 1000: - self.skipTest('This example would probably take too long') + s.max_time = 100 + s.num_trials = 1 + if config.get('skip_test', False) and not FORCE_TESTS: + self.skipTest('Example ignored.') envs = s.run_simulation(dry_run=True) assert envs for env in envs: diff --git a/tests/test_main.py b/tests/test_main.py index 12b3ab4..8e8ee71 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -320,3 +320,19 @@ class TestMain(TestCase): h = history.History() h.save_record(agent_id=0, t_step=0, key="test", value="hello") assert h[0, 0, "test"] == "hello" + + def test_subgraph(self): + '''An agent should be able to subgraph the global topology''' + G = nx.Graph() + G.add_node(3) + G.add_edge(1, 2) + distro = agents.calculate_distribution(agent_type=agents.NetworkAgent) + env = Environment(name='Test', topology=G, network_agents=distro) + lst = list(env.network_agents) + + a2 = env.get_agent(2) + a3 = env.get_agent(3) + assert len(a2.subgraph(limit_neighbors=True)) == 2 + assert len(a3.subgraph(limit_neighbors=True)) == 1 + assert len(a3.subgraph(limit_neighbors=True, center=False)) == 0 + assert len(a3.subgraph(agent_type=agents.NetworkAgent)) == 3