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 # 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

45 def 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

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

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

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

60

61

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

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

64 return model.movements/model.schedule.time

65

66

67 class 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)