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

1 

2import random 

3from pathlib import Path 

4#from statistics import mean 

5from tqdm import tqdm 

6from numpy import mean 

7 

8import mesa 

9import networkx as nx 

10import mesa_geo as mg 

11from shapely import contains 

12 

13from scicom.utilities.statistics import prune 

14from scicom.historicalletters.utils import createData 

15 

16from scicom.historicalletters.agents import SenderAgent, RegionAgent 

17from scicom.historicalletters.space import Nuts2Eu 

18 

19 

20def getPrunedLedger(model): 

21 """Model reporter for simulation of archiving. 

22  

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 

44 

45def getComponents(model): 

46 """Model reporter to get number of components. 

47  

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 

56 

57 

58class HistoricalLetters(mesa.Model): 

59 """A letter sending model with historical informed initital positions. 

60  

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] 

64  

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. 

69 

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__() 

91 

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 

115 

116 initSenderGeoDf = createData( 

117 population, 

118 populationDistribution=populationDistributionData 

119 ) 

120 

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) 

130 

131 self.factors = dict( 

132 updateTopic=updateTopic, 

133 similarityThreshold=similarityThreshold, 

134 moveRange=moveRange, 

135 letterRange=letterRange, 

136 ) 

137 

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) 

145 

146 # Set up agent creator for senders 

147 ac_senders = mg.AgentCreator( 

148 SenderAgent, 

149 model=self, 

150 agent_kwargs=self.factors 

151 ) 

152 

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 ) 

159 

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 ] 

166 

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() 

182 

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) 

192 

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) 

196 

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) 

202 

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 ) 

212 

213 def _createSocialEdges(self, agent, graph): 

214 """Create social edges with the different wiring factors. 

215 

216 Define a close range by using the moveRange parameter. Among 

217 these neighbors, create a connection with probability set by 

218 the shortRangeNetworkFactor.  

219 

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) 

249 

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) 

256 

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()