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

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=10,

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

46 delTypes=("degree", "unif", "exp", "beta"),

47 )

48 else:

49 result = model.letterLedger

50 return result

51

52

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

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

55

56 The MultiDiGraph is converted to undirected,

57 considering only edges that are reciprocal, ie.

58 edges are established if sender and receiver have

59 exchanged at least a letter in each direction.

60 """

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

62 return nx.number_connected_components(newg)

63

64

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

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

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

68

69

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

71 """Return relative number of movements."""

72 return model.movements/model.schedule.time

73

74

75 class HistoricalLetters(mesa.Model):

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

77

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

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

80 based on data from [1].

81

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

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

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

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

86

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

88 PLoS ONE 11(10): e0162678.

89 """

90

91 def __init__(

92 self,

93 population: int = 100,

94 moveRange: float = 0.05,

95 letterRange: float = 0.2,

96 similarityThreshold: float = 0.2,

97 longRangeNetworkFactor: float = 0.3,

98 shortRangeNetworkFactor: float = 0.4,

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

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

101 *,

102 useActivation: bool = False,

103 useSocialNetwork: bool = False,

104 runPruning: bool = False,

105 debug: bool = False,

106 ) -> None:

107 """Initialize a HistoricalLetters model."""

108 super().__init__()

109

110 # Parameters for agents

111 self.population = population

112 self.moveRange = moveRange

113 self.letterRange = letterRange

114 # Parameters for model

115 self.runPruning = runPruning

116 self.useActivation = useActivation

117 self.similarityThreshold = similarityThreshold

118 self.useSocialNetwork = useSocialNetwork

119 self.longRangeNetworkFactor = longRangeNetworkFactor

120 self.shortRangeNetworkFactor = shortRangeNetworkFactor

121 # Initialize social network

122 self.socialNetwork = nx.MultiDiGraph()

123 # Output variables

124 self.letterLedger = []

125 self.movements = 0

126 # Internal variables

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

128 self.scaleSendInput = {}

129 self.updatedTopicsDict = {}

130 self.space = Nuts2Eu()

131 self.debug = debug

132

133 #######

134 # Initialize region agents

135 #######

136

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

138 # Create region agents

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

140 self.regions = ac.from_file(

141 regionData,

142 unique_id="NUTS_ID",

143 )

144 # Add regions to Nuts2Eu geospace

145 self.space.add_regions(self.regions)

146

147 #######

148 # Initialize sender agents

149 #######

150

151 # Draw initial geographic positions of agents

152 initSenderGeoDf = createData(

153 population,

154 populationDistribution=populationDistributionData,

155 )

156

157 # Calculate mean of mean distances for each agent.

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

159 meandistances = []

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

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

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

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

164 geometries = otherAgents.geometry.to_numpy()

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

166 meandistances.append(mean(distances))

167 self.meandistance = mean(meandistances)

168

169 # Populate factors dictionary

170 self.factors = {

171 "similarityThreshold": similarityThreshold,

172 "moveRange": moveRange,

173 "letterRange": letterRange,

174 }

175

176 # Set up agent creator for senders

177 ac_senders = mg.AgentCreator(

178 SenderAgent,

179 model=self,

180 agent_kwargs=self.factors,

181 )

182

183 # Create agents based on random coordinates generated

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

185 senders = ac_senders.from_GeoDataFrame(

186 initSenderGeoDf,

187 unique_id="unique_id",

188 )

189

190 # Create random set of initial topic vectors.

191 topics = [

192 tuple(

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

194 ) for x in range(self.population)

195 ]

196

197 # Setup senders

198 for idx, sender in enumerate(senders):

199 # Add to social network

200 self.socialNetwork.add_node(

201 sender.unique_id,

202 numLettersSend=0,

203 numLettersReceived=0,

204 )

205 # Give sender topic

206 sender.topicVec = topics[idx]

207 # Add current topic to dict

208 self.updatedTopicsDict.update(

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

210 )

211 # Set random activation weight

212 if useActivation is True:

213 sender.activationWeight = random.random()

214 # Add sender to its region

215 regionID = [

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

217 ]

218 try:

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

220 except IndexError as exc:

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

222 raise IndexError(text) from exc

223 # Add sender to schedule

224 self.schedule.add(sender)

225

226 # Create social network

227 if useSocialNetwork is True:

228 for agent in self.schedule.agents:

229 if isinstance(agent, SenderAgent):

230 self._createSocialEdges(agent, self.socialNetwork)

231

232 self.datacollector = mesa.DataCollector(

233 model_reporters={

234 "Ledger": getPrunedLedger,

235 "Letters": getScaledLetters ,

236 "Movements": getScaledMovements,

237 "Clusters": getComponents,

238 },

239 )

240

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

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

243

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

245 these neighbors, create a connection with probability set by

246 the shortRangeNetworkFactor.

247

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

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

250 """

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

252 agent,

253 distance=self.moveRange * self.meandistance,

254 center=False,

255 ) if isinstance(x, SenderAgent)]

256 for neighbor in closerange:

257 if neighbor.unique_id != agent.unique_id:

258 connect = random.choices(

259 population=[True, False],

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

261 k=1,

262 )

263 if connect[0] is True:

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

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

266 for neighbor in longrange:

267 if neighbor.unique_id != agent.unique_id:

268 connect = random.choices(

269 population=[True, False],

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

271 k=1,

272 )

273 if connect[0] is True:

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

275

276 def step(self) -> None:

277 """One simulation step."""

278 self.scaleSendInput.update(

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

280 )

281 self.schedule.step()

282 self.datacollector.collect(self)

283

284 def step_no_data(self) -> None:

285 """One simulation step without datacollection."""

286 self.scaleSendInput.update(

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

288 )

289 self.schedule.step()

290

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

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

293 if self.debug is True:

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

295 self.step_no_data()

296 else:

297 for _ in range(n):

298 self.step_no_data()

299 self.datacollector.collect(self)