Coverage for src/scicom/historicalletters/model.py: 0%
102 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-15 13:26 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-15 13:26 +0200
2import random
3from pathlib import Path
4#from statistics import mean
5from tqdm import tqdm
6from numpy import mean
8import mesa
9import networkx as nx
10import mesa_geo as mg
11from shapely import contains
13from scicom.utilities.statistics import prune
14from scicom.historicalletters.utils import createData
16from scicom.historicalletters.agents import SenderAgent, RegionAgent
17from scicom.historicalletters.space import Nuts2Eu
20def getPrunedLedger(model):
21 """Model reporter for simulation of archiving.
23 Returns statistics of ledger network of model run
24 and various iterations of statistics of pruned networks.
25 """
26 # TODO: Add all model params
27 if model.runPruning is True:
28 ledgerColumns = ['sender', 'receiver', 'sender_location', 'receiver_location', 'topic', 'step']
29 modelparams = {
30 "population": model.population,
31 "moveRange": model.moveRange,
32 "letterRange": model.letterRange,
33 "useActivation": model.useActivation,
34 "useSocialNetwork": model.useSocialNetwork,
35 }
36 result = prune(
37 modelparameters=modelparams,
38 network=model.letterLedger,
39 columns=ledgerColumns
40 )
41 else:
42 result = model.letterLedger
43 return result
45def getComponents(model):
46 """Model reporter to get number of components.
48 The MultiDiGraph is converted to undirected,
49 considering only edges that are reciprocal, ie.
50 edges are established if sender and receiver have
51 exchanged at least a letter in each direction.
52 """
53 newG = model.G.to_undirected(reciprocal=True)
54 comp = nx.number_connected_components(newG)
55 return comp
58class HistoricalLetters(mesa.Model):
59 """A letter sending model with historical informed initital positions.
61 Each agent has an initial topic vector, expressed as a RGB value. The
62 initial positions of the agents is based on a weighted random draw
63 based on data from [1]
65 Each step, agents generate two neighbourhoods for sending letters and
66 potential targets to move towards. The probability to send letters is
67 a self-reinforcing process. During each sending the internal topic of
68 the sender is updated as a random rotation towards the receivers topic.
70 [1] J. Lobo et al, Population-Area Relationship for Medieval European Cities,
71 PLoS ONE 11(10): e0162678.
72 """
73 def __init__(
74 self,
75 population: int = 100,
76 moveRange: float = 0.05,
77 letterRange: float = 0.2,
78 similarityThreshold: float = 0.2,
79 updateTopic: float = 0.1,
80 useActivation=False,
81 useSocialNetwork=False,
82 longRangeNetworkFactor=0.3,
83 shortRangeNetworkFactor=0.8,
84 runPruning=False,
85 regionData: str = Path(Path(__file__).parent.parent.resolve(), "data/NUTS_RG_60M_2021_3857_LEVL_2.geojson"),
86 populationDistributionData: str = Path(Path(__file__).parent.parent.resolve(), "data/pone.0162678.s003.csv"),
87 tempfolder: str = "./",
88 debug=False
89 ):
90 super().__init__()
92 # Control variables
93 self.population = population
94 self.moveRange = moveRange
95 self.letterRange = letterRange
96 self.useActivation = useActivation
97 # Initialize social network
98 self.useSocialNetwork = useSocialNetwork
99 self.G = nx.MultiDiGraph()
100 self.longRangeNetworkFactor = longRangeNetworkFactor
101 self.shortRangeNetworkFactor = shortRangeNetworkFactor
102 # Collected output variables
103 self.letterLedger = []
104 self.runPruning = runPruning
105 self.movements = 0
106 self.updatedTopic = 0
107 # Internal variables
108 self.schedule = mesa.time.RandomActivation(self)
109 self.scaleSendInput = {}
110 self.updatedTopicsDict = {}
111 self.space = Nuts2Eu()
112 self.personRegionMap = {}
113 self.tempfolder = tempfolder
114 self.debug = debug
116 initSenderGeoDf = createData(
117 population,
118 populationDistribution=populationDistributionData
119 )
121 # Calculate mean of mean distances for each agent.
122 # This is used as a measure for the range of exchanges.
123 distances = []
124 for idx, row in initSenderGeoDf.iterrows():
125 p1 = row['geometry']
126 distances.append(
127 initSenderGeoDf.geometry.apply(lambda x: p1.distance(x)).mean()
128 )
129 self.meandistance = mean(distances)
131 self.factors = dict(
132 updateTopic=updateTopic,
133 similarityThreshold=similarityThreshold,
134 moveRange=moveRange,
135 letterRange=letterRange,
136 )
138 # Set up the grid with patches for every NUTS region
139 ac = mg.AgentCreator(RegionAgent, model=self)
140 self.regions = ac.from_file(
141 regionData,
142 unique_id="NUTS_ID"
143 )
144 self.space.add_regions(self.regions)
146 # Set up agent creator for senders
147 ac_senders = mg.AgentCreator(
148 SenderAgent,
149 model=self,
150 agent_kwargs=self.factors
151 )
153 # Create agents based on random coordinates generated
154 # in the createData step above, see util.py file.
155 senders = ac_senders.from_GeoDataFrame(
156 initSenderGeoDf,
157 unique_id="unique_id"
158 )
160 # Create random set of initial topic vectors.
161 topics = [
162 tuple(
163 [random.random() for x in range(3)]
164 ) for x in range(self.population)
165 ]
167 # Attach topic and activationWeight to each agent,
168 # connect to social network graph.
169 for idx, sender in enumerate(senders):
170 self.G.add_node(
171 sender.unique_id,
172 numLettersSend=0,
173 numLettersReceived=0
174 )
175 sender.topicVec = topics[idx]
176 # Add current topic to dict
177 self.updatedTopicsDict.update(
178 {sender.unique_id: topics[idx]}
179 )
180 if useActivation is True:
181 sender.activationWeight = random.random()
183 for agent in senders:
184 regionID = [
185 x.unique_id for x in self.regions if contains(x.geometry, agent.geometry)
186 ]
187 try:
188 self.space.add_sender(agent, regionID[0])
189 except IndexError:
190 raise IndexError(f"Problem finding region for {agent.geometry}.")
191 self.schedule.add(agent)
193 # Add graph to network grid for potential visualization.
194 # TODO: Not yet implemented. Maybe use Solara backend for this?
195 self.grid = mesa.space.NetworkGrid(self.G)
197 # Create social network
198 if useSocialNetwork is True:
199 for agent in self.schedule.agents:
200 if isinstance(agent, SenderAgent):
201 self._createSocialEdges(agent, self.G)
203 # TODO: What comparitive values are useful for visualizations?
204 self.datacollector = mesa.DataCollector(
205 model_reporters={
206 "Ledger": getPrunedLedger,
207 "Letters": lambda x: len(self.letterLedger),
208 "Movements": lambda x: self.movements,
209 "Clusters": getComponents
210 },
211 )
213 def _createSocialEdges(self, agent, graph):
214 """Create social edges with the different wiring factors.
216 Define a close range by using the moveRange parameter. Among
217 these neighbors, create a connection with probability set by
218 the shortRangeNetworkFactor.
220 For all other agents, that are not in this closeRange group,
221 create a connection with the probability set by the longRangeNetworkFactor.
222 """
223 closerange = [x for x in self.space.get_neighbors_within_distance(
224 agent,
225 distance=self.moveRange * self.meandistance,
226 center=False
227 ) if isinstance(x, SenderAgent)]
228 for neighbor in closerange:
229 if neighbor.unique_id != agent.unique_id:
230 connect = random.choices(
231 population=[True, False],
232 weights=[self.shortRangeNetworkFactor, 1 - self.shortRangeNetworkFactor],
233 k=1
234 )
235 if connect[0] is True:
236 graph.add_edge(agent.unique_id, neighbor.unique_id, step=0)
237 graph.add_edge(neighbor.unique_id, agent.unique_id, step=0)
238 longrange = [x for x in self.schedule.agents if x not in closerange and isinstance(x, SenderAgent)]
239 for neighbor in longrange:
240 if neighbor.unique_id != agent.unique_id:
241 connect = random.choices(
242 population=[True, False],
243 weights=[self.longRangeNetworkFactor, 1 - self.longRangeNetworkFactor],
244 k=1
245 )
246 if connect[0] is True:
247 graph.add_edge(agent.unique_id, neighbor.unique_id, step=0)
248 graph.add_edge(neighbor.unique_id, agent.unique_id, step=0)
250 def step(self):
251 self.scaleSendInput.update(
252 **{x.unique_id: x.numLettersReceived for x in self.schedule.agents}
253 )
254 self.schedule.step()
255 self.datacollector.collect(self)
257 def run(self, n):
258 """Run the model for n steps."""
259 if self.debug is True:
260 for _ in tqdm(range(n)):
261 self.step()
262 else:
263 for _ in range(n):
264 self.step()