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

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

2 import random

3 from pathlib import Path

4

5 import mesa

6 import mesa_geo as mg

7 import networkx as nx

8 import pandas as pd

9 from numpy import mean

10 from shapely import contains

11 from tqdm import tqdm

12

13 from scicom.historicalletters.agents import RegionAgent, SenderAgent

14 from scicom.historicalletters.space import Nuts2Eu

15 from scicom.historicalletters.utils import createData

16 from scicom.utilities.statistics import prune

17

18

19 def 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 The routine assumes that the network contains fields of sender,

26 receiver and step information.

27 """

28 if model.runPruning is True:

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

30 modelparams = {

31 "population": model.population,

32 "moveRange": model.moveRange,

33 "letterRange": model.letterRange,

34 "useActivation": model.useActivation,

35 "useSocialNetwork": model.useSocialNetwork,

36 "similarityThreshold": model.similarityThreshold,

37 "longRangeNetworkFactor": model.longRangeNetworkFactor,

38 "shortRangeNetworkFactor": model.shortRangeNetworkFactor,

39 }

40 result = prune(

41 modelparameters=modelparams,

42 network=model.letterLedger,

43 columns=ledgerColumns,

44 iterations=3,

45 delAmounts=(0.1, 0.25, 0.5, 0.75, 0.9),

46 delTypes=("unif", "exp", "beta", "log_normal1", "log_normal2", "log_normal3"),

47 delMethod=("agents", "regions", "time"),

48 rankedVals=(True, False),

49 )

50 else:

51 result = model.letterLedger

52 return result

53

54

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

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

57

58 The MultiDiGraph is converted to undirected,

59 considering only edges that are reciprocal, ie.

60 edges are established if sender and receiver have

61 exchanged at least a letter in each direction.

62 """

63 newg = model.socialNetwork.to_undirected(reciprocal=True)

64 return nx.number_connected_components(newg)

65

66

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

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

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

70

71

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

73 """Return relative number of movements."""

74 return model.movements/model.schedule.time

75

76

77 class HistoricalLetters(mesa.Model):

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

79

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

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

82 based on data from [1].

83

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

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

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

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

88

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

90 PLoS ONE 11(10): e0162678.

91 """

92

93 def __init__(

94 self,

95 population: int = 100,

96 moveRange: float = 0.05,

97 letterRange: float = 0.2,

98 similarityThreshold: float = 0.2,

99 longRangeNetworkFactor: float = 0.3,

100 shortRangeNetworkFactor: float = 0.4,

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

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

103 *,

104 useActivation: bool = False,

105 useSocialNetwork: bool = False,

106 runPruning: bool = False,

107 debug: bool = False,

108 ) -> None:

109 """Initialize a HistoricalLetters model."""

110 super().__init__()

111

112 # Parameters for agents

113 self.population = population

114 self.moveRange = moveRange

115 self.letterRange = letterRange

116 # Parameters for model

117 self.runPruning = runPruning

118 self.useActivation = useActivation

119 self.similarityThreshold = similarityThreshold

120 self.useSocialNetwork = useSocialNetwork

121 self.longRangeNetworkFactor = longRangeNetworkFactor

122 self.shortRangeNetworkFactor = shortRangeNetworkFactor

123 # Initialize social network

124 self.socialNetwork = nx.MultiDiGraph()

125 # Output variables

126 self.letterLedger = []

127 self.movements = 0

128 # Internal variables

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

130 self.scaleSendInput = {}

131 self.updatedTopicsDict = {}

132 self.updatedPositionDict = {}

133 self.space = Nuts2Eu()

134 self.debug = debug

135

136 #######

137 # Initialize region agents

138 #######

139

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

141 # Create region agents

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

143 self.regions = ac.from_file(

144 regionData,

145 unique_id="NUTS_ID",

146 )

147 # Add regions to Nuts2Eu geospace

148 self.space.add_regions(self.regions)

149

150 #######

151 # Initialize sender agents

152 #######

153

154 # Draw initial geographic positions of agents

155 initSenderGeoDf = createData(

156 population,

157 populationDistribution=populationDistributionData,

158 )

159

160 # Calculate mean of mean distances for each agent.

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

162 meandistances = []

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

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

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

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

167 geometries = otherAgents.geometry.to_numpy()

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

169 meandistances.append(mean(distances))

170 self.meandistance = mean(meandistances)

171

172 # Populate factors dictionary

173 self.factors = {

174 "similarityThreshold": similarityThreshold,

175 "moveRange": moveRange,

176 "letterRange": letterRange,

177 }

178

179 # Set up agent creator for senders

180 ac_senders = mg.AgentCreator(

181 SenderAgent,

182 model=self,

183 agent_kwargs=self.factors,

184 )

185

186 # Create agents based on random coordinates generated

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

188 senders = ac_senders.from_GeoDataFrame(

189 initSenderGeoDf,

190 unique_id="unique_id",

191 )

192

193 # Create random set of initial topic vectors.

194 topics = [

195 tuple(

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

197 ) for x in range(self.population)

198 ]

199

200 # Setup senders

201 for idx, sender in enumerate(senders):

202 # Add to social network

203 self.socialNetwork.add_node(

204 sender.unique_id,

205 numLettersSend=0,

206 numLettersReceived=0,

207 )

208 # Give sender topic

209 sender.topicVec = topics[idx]

210 # Add current topic to dict

211 self.updatedTopicsDict.update(

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

213 )

214 # Set random activation weight

215 if useActivation is True:

216 sender.activationWeight = random.random()

217 # Add sender to its region

218 regionID = [

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

220 ]

221 try:

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

223 except IndexError as exc:

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

225 raise IndexError(text) from exc

226 # Prepopulate positions dict

227 self.updatedPositionDict.update(

228 {sender.unique_id: [sender.geometry, regionID[0]]},

229 )

230 # Add sender to schedule

231 self.schedule.add(sender)

232

233 # Create social network

234 if useSocialNetwork is True:

235 for agent in self.schedule.agents:

236 if isinstance(agent, SenderAgent):

237 self._createSocialEdges(agent, self.socialNetwork)

238

239 self.datacollector = mesa.DataCollector(

240 model_reporters={

241 "Ledger": getPrunedLedger,

242 "Letters": getScaledLetters ,

243 "Movements": getScaledMovements,

244 "Clusters": getComponents,

245 },

246 )

247

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

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

250

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

252 these neighbors, create a connection with probability set by

253 the shortRangeNetworkFactor.

254

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

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

257 """

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

259 agent,

260 distance=self.moveRange * self.meandistance,

261 center=False,

262 ) if isinstance(x, SenderAgent)]

263 for neighbor in closerange:

264 if neighbor.unique_id != agent.unique_id:

265 connect = random.choices(

266 population=[True, False],

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

268 k=1,

269 )

270 if connect[0] is True:

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

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

273 for neighbor in longrange:

274 if neighbor.unique_id != agent.unique_id:

275 connect = random.choices(

276 population=[True, False],

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

278 k=1,

279 )

280 if connect[0] is True:

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

282

283 def step(self) -> None:

284 """One simulation step with data collection."""

285 self.step_no_data()

286 self.datacollector.collect(self)

287

288 def step_no_data(self) -> None:

289 """One simulation step without data collection."""

290 self.scaleSendInput.update(

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

292 )

293 # Update the currently held topicVec for each agent, based

294 # on potential previouse communication events and the position

295 # based on a previous movement.

296 for agent in self.schedule.agents:

297 agent.topicVec = self.updatedTopicsDict[agent.unique_id]

298 newGeom = self.updatedPositionDict[agent.unique_id]

299 if newGeom[0] != agent.geometry:

300 self.space.move_sender(agent, newGeom[0], newGeom[1])

301 self.schedule.step()

302

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

304 """Run the model for n steps.

305

306 Data collection is only run at the end of n steps.

307 This is useful for batch runs accross different

308 parameters.

309 """

310 if self.debug is True:

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

312 self.step_no_data()

313 else:

314 for _ in range(n):

315 self.step_no_data()

316 self.datacollector.collect(self)