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

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.space = Nuts2Eu()

133 self.debug = debug

134

135 #######

136 # Initialize region agents

137 #######

138

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

140 # Create region agents

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

142 self.regions = ac.from_file(

143 regionData,

144 unique_id="NUTS_ID",

145 )

146 # Add regions to Nuts2Eu geospace

147 self.space.add_regions(self.regions)

148

149 #######

150 # Initialize sender agents

151 #######

152

153 # Draw initial geographic positions of agents

154 initSenderGeoDf = createData(

155 population,

156 populationDistribution=populationDistributionData,

157 )

158

159 # Calculate mean of mean distances for each agent.

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

161 meandistances = []

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

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

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

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

166 geometries = otherAgents.geometry.to_numpy()

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

168 meandistances.append(mean(distances))

169 self.meandistance = mean(meandistances)

170

171 # Populate factors dictionary

172 self.factors = {

173 "similarityThreshold": similarityThreshold,

174 "moveRange": moveRange,

175 "letterRange": letterRange,

176 }

177

178 # Set up agent creator for senders

179 ac_senders = mg.AgentCreator(

180 SenderAgent,

181 model=self,

182 agent_kwargs=self.factors,

183 )

184

185 # Create agents based on random coordinates generated

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

187 senders = ac_senders.from_GeoDataFrame(

188 initSenderGeoDf,

189 unique_id="unique_id",

190 )

191

192 # Create random set of initial topic vectors.

193 topics = [

194 tuple(

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

196 ) for x in range(self.population)

197 ]

198

199 # Setup senders

200 for idx, sender in enumerate(senders):

201 # Add to social network

202 self.socialNetwork.add_node(

203 sender.unique_id,

204 numLettersSend=0,

205 numLettersReceived=0,

206 )

207 # Give sender topic

208 sender.topicVec = topics[idx]

209 # Add current topic to dict

210 self.updatedTopicsDict.update(

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

212 )

213 # Set random activation weight

214 if useActivation is True:

215 sender.activationWeight = random.random()

216 # Add sender to its region

217 regionID = [

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

219 ]

220 try:

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

222 except IndexError as exc:

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

224 raise IndexError(text) from exc

225 # Add sender to schedule

226 self.schedule.add(sender)

227

228 # Create social network

229 if useSocialNetwork is True:

230 for agent in self.schedule.agents:

231 if isinstance(agent, SenderAgent):

232 self._createSocialEdges(agent, self.socialNetwork)

233

234 self.datacollector = mesa.DataCollector(

235 model_reporters={

236 "Ledger": getPrunedLedger,

237 "Letters": getScaledLetters ,

238 "Movements": getScaledMovements,

239 "Clusters": getComponents,

240 },

241 )

242

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

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

245

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

247 these neighbors, create a connection with probability set by

248 the shortRangeNetworkFactor.

249

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

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

252 """

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

254 agent,

255 distance=self.moveRange * self.meandistance,

256 center=False,

257 ) if isinstance(x, SenderAgent)]

258 for neighbor in closerange:

259 if neighbor.unique_id != agent.unique_id:

260 connect = random.choices(

261 population=[True, False],

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

263 k=1,

264 )

265 if connect[0] is True:

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

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

268 for neighbor in longrange:

269 if neighbor.unique_id != agent.unique_id:

270 connect = random.choices(

271 population=[True, False],

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

273 k=1,

274 )

275 if connect[0] is True:

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

277

278 def step(self) -> None:

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

280 self.step_no_data()

281 self.datacollector.collect(self)

282

283 def step_no_data(self) -> None:

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

285 self.scaleSendInput.update(

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

287 )

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

289 # on potential previouse communication events.

290 for agent in self.schedule.agents:

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

292 self.schedule.step()

293

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

295 """Run the model for n steps.

296

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

298 This is useful for batch runs accross different

299 parameters.

300 """

301 if self.debug is True:

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

303 self.step_no_data()

304 else:

305 for _ in range(n):

306 self.step_no_data()

307 self.datacollector.collect(self)