Coverage for .tox/p311/lib/python3.10/site-packages/scicom/historicalletters/model.py: 94%

108 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-26 14:26 +0200

1"""The model class for HistoricalLetters.""" 

2import random 

3from pathlib import Path 

4 

5import mesa 

6import mesa_geo as mg 

7import networkx as nx 

8import pandas as pd 

9from numpy import mean 

10from shapely import contains 

11from tqdm import tqdm 

12 

13from scicom.historicalletters.agents import RegionAgent, SenderAgent 

14from scicom.historicalletters.space import Nuts2Eu 

15from scicom.historicalletters.utils import createData 

16from scicom.utilities.statistics import prune 

17 

18 

19def getPrunedLedger(model: mesa.Model) -> pd.DataFrame: 

20 """Model reporter for simulation of archiving. 

21 

22 Returns statistics of ledger network of model run 

23 and various iterations of statistics of pruned networks. 

24 """ 

25 # TODO(malte): Add all model params 

26 if model.runPruning is True: 

27 ledgerColumns = ["sender", "receiver", "sender_location", "receiver_location", "topic", "step"] 

28 modelparams = { 

29 "population": model.population, 

30 "moveRange": model.moveRange, 

31 "letterRange": model.letterRange, 

32 "useActivation": model.useActivation, 

33 "useSocialNetwork": model.useSocialNetwork, 

34 } 

35 result = prune( 

36 modelparameters=modelparams, 

37 network=model.letterLedger, 

38 columns=ledgerColumns, 

39 ) 

40 else: 

41 result = model.letterLedger 

42 return result 

43 

44 

45def getComponents(model: mesa.Model) -> int: 

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.socialNetwork.to_undirected(reciprocal=True) 

54 return nx.number_connected_components(newg) 

55 

56 

57def getScaledLetters(model: mesa.Model) -> float: 

58 """Return relative number of send letters.""" 

59 return len(model.letterLedger)/model.schedule.time 

60 

61 

62def getScaledMovements(model: mesa.Model) -> float: 

63 """Return relative number of movements.""" 

64 return model.movements/model.schedule.time 

65 

66 

67class HistoricalLetters(mesa.Model): 

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

69 

70 Each agent has an initial topic vector, expressed as a RGB value. The 

71 initial positions of the agents is based on a weighted random draw 

72 based on data from [1]. 

73 

74 Each step, agents generate two neighbourhoods for sending letters and 

75 potential targets to move towards. The probability to send letters is 

76 a self-reinforcing process. During each sending the internal topic of 

77 the sender is updated as a random rotation towards the receivers topic. 

78 

79 [1] J. Lobo et al, Population-Area Relationship for Medieval European Cities, 

80 PLoS ONE 11(10): e0162678. 

81 """ 

82 

83 def __init__( 

84 self, 

85 population: int = 100, 

86 moveRange: float = 0.05, 

87 letterRange: float = 0.2, 

88 similarityThreshold: float = 0.2, 

89 longRangeNetworkFactor: float = 0.3, 

90 shortRangeNetworkFactor: float = 0.4, 

91 regionData: str = Path(Path(__file__).parent.parent.resolve(), "data/NUTS_RG_60M_2021_3857_LEVL_2.geojson"), 

92 populationDistributionData: str = Path(Path(__file__).parent.parent.resolve(), "data/pone.0162678.s003.csv"), 

93 *, 

94 useActivation: bool = False, 

95 useSocialNetwork: bool = False, 

96 runPruning: bool = False, 

97 debug: bool = False, 

98 ) -> None: 

99 """Initialize a HistoricalLetters model.""" 

100 super().__init__() 

101 

102 # Parameters for agents 

103 self.population = population 

104 self.moveRange = moveRange 

105 self.letterRange = letterRange 

106 # Parameters for model 

107 self.runPruning = runPruning 

108 self.useActivation = useActivation 

109 self.useSocialNetwork = useSocialNetwork 

110 self.longRangeNetworkFactor = longRangeNetworkFactor 

111 self.shortRangeNetworkFactor = shortRangeNetworkFactor 

112 # Initialize social network 

113 self.socialNetwork = nx.MultiDiGraph() 

114 # Output variables 

115 self.letterLedger = [] 

116 self.movements = 0 

117 # Internal variables 

118 self.schedule = mesa.time.RandomActivation(self) 

119 self.scaleSendInput = {} 

120 self.updatedTopicsDict = {} 

121 self.space = Nuts2Eu() 

122 self.debug = debug 

123 

124 ####### 

125 # Initialize region agents 

126 ####### 

127 

128 # Set up the grid with patches for every NUTS region 

129 # Create region agents 

130 ac = mg.AgentCreator(RegionAgent, model=self) 

131 self.regions = ac.from_file( 

132 regionData, 

133 unique_id="NUTS_ID", 

134 ) 

135 # Add regions to Nuts2Eu geospace 

136 self.space.add_regions(self.regions) 

137 

138 ####### 

139 # Initialize sender agents 

140 ####### 

141 

142 # Draw initial geographic positions of agents 

143 initSenderGeoDf = createData( 

144 population, 

145 populationDistribution=populationDistributionData, 

146 ) 

147 

148 # Calculate mean of mean distances for each agent. 

149 # This is used as a measure for the range of exchanges. 

150 meandistances = [] 

151 for idx in initSenderGeoDf.index.to_numpy(): 

152 name = initSenderGeoDf.loc[idx, "unique_id"] 

153 geom = initSenderGeoDf.loc[idx, "geometry"] 

154 otherAgents = initSenderGeoDf.query(f"unique_id != '{name}'").copy() 

155 geometries = otherAgents.geometry.to_numpy() 

156 distances = [geom.distance(othergeom) for othergeom in geometries] 

157 meandistances.append(mean(distances)) 

158 self.meandistance = mean(meandistances) 

159 

160 # Populate factors dictionary 

161 self.factors = { 

162 "similarityThreshold": similarityThreshold, 

163 "moveRange": moveRange, 

164 "letterRange": letterRange, 

165 } 

166 

167 # Set up agent creator for senders 

168 ac_senders = mg.AgentCreator( 

169 SenderAgent, 

170 model=self, 

171 agent_kwargs=self.factors, 

172 ) 

173 

174 # Create agents based on random coordinates generated 

175 # in the createData step above, see util.py file. 

176 senders = ac_senders.from_GeoDataFrame( 

177 initSenderGeoDf, 

178 unique_id="unique_id", 

179 ) 

180 

181 # Create random set of initial topic vectors. 

182 topics = [ 

183 tuple( 

184 [random.random() for x in range(3)], 

185 ) for x in range(self.population) 

186 ] 

187 

188 # Setup senders 

189 for idx, sender in enumerate(senders): 

190 # Add to social network 

191 self.socialNetwork.add_node( 

192 sender.unique_id, 

193 numLettersSend=0, 

194 numLettersReceived=0, 

195 ) 

196 # Give sender topic 

197 sender.topicVec = topics[idx] 

198 # Add current topic to dict 

199 self.updatedTopicsDict.update( 

200 {sender.unique_id: topics[idx]}, 

201 ) 

202 # Set random activation weight 

203 if useActivation is True: 

204 sender.activationWeight = random.random() 

205 # Add sender to its region 

206 regionID = [ 

207 x.unique_id for x in self.regions if contains(x.geometry, sender.geometry) 

208 ] 

209 try: 

210 self.space.add_sender(sender, regionID[0]) 

211 except IndexError as exc: 

212 text = f"Problem finding region for {sender.geometry}." 

213 raise IndexError(text) from exc 

214 # Add sender to schedule 

215 self.schedule.add(sender) 

216 

217 # Add graph to network grid for potential visualization. 

218 # TODO(malte): Not yet implemented. Maybe use Solara backend for this? 

219 # self.grid = mesa.space.NetworkGrid(self.socialNetwork) 

220 

221 # Create social network 

222 if useSocialNetwork is True: 

223 for agent in self.schedule.agents: 

224 if isinstance(agent, SenderAgent): 

225 self._createSocialEdges(agent, self.socialNetwork) 

226 

227 self.datacollector = mesa.DataCollector( 

228 model_reporters={ 

229 "Ledger": getPrunedLedger, 

230 "Letters": getScaledLetters , 

231 "Movements": getScaledMovements, 

232 "Clusters": getComponents, 

233 }, 

234 ) 

235 

236 def _createSocialEdges(self, agent: SenderAgent, graph: nx.MultiDiGraph) -> None: 

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

238 

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

240 these neighbors, create a connection with probability set by 

241 the shortRangeNetworkFactor. 

242 

243 For all other agents, that are not in this closeRange group, 

244 create a connection with the probability set by the longRangeNetworkFactor. 

245 """ 

246 closerange = [x for x in self.space.get_neighbors_within_distance( 

247 agent, 

248 distance=self.moveRange * self.meandistance, 

249 center=False, 

250 ) if isinstance(x, SenderAgent)] 

251 for neighbor in closerange: 

252 if neighbor.unique_id != agent.unique_id: 

253 connect = random.choices( 

254 population=[True, False], 

255 weights=[self.shortRangeNetworkFactor, 1 - self.shortRangeNetworkFactor], 

256 k=1, 

257 ) 

258 if connect[0] is True: 

259 graph.add_edge(agent.unique_id, neighbor.unique_id, step=0) 

260 longrange = [x for x in self.schedule.agents if x not in closerange and isinstance(x, SenderAgent)] 

261 for neighbor in longrange: 

262 if neighbor.unique_id != agent.unique_id: 

263 connect = random.choices( 

264 population=[True, False], 

265 weights=[self.longRangeNetworkFactor, 1 - self.longRangeNetworkFactor], 

266 k=1, 

267 ) 

268 if connect[0] is True: 

269 graph.add_edge(agent.unique_id, neighbor.unique_id, step=0) 

270 

271 def step(self) -> None: 

272 """One simulation step.""" 

273 self.scaleSendInput.update( 

274 **{x.unique_id: x.numLettersReceived for x in self.schedule.agents}, 

275 ) 

276 self.schedule.step() 

277 self.datacollector.collect(self) 

278 

279 def step_no_data(self) -> None: 

280 """One simulation step without datacollection.""" 

281 self.scaleSendInput.update( 

282 **{x.unique_id: x.numLettersReceived for x in self.schedule.agents}, 

283 ) 

284 self.schedule.step() 

285 

286 def run(self, n:int) -> None: 

287 """Run the model for n steps.""" 

288 if self.debug is True: 

289 for _ in tqdm(range(n)): 

290 self.step_no_data() 

291 else: 

292 for _ in range(n): 

293 self.step_no_data() 

294 self.datacollector.collect(self)