Coverage for .tox/p313/lib/python3.13/site-packages/scicom/historicalletters/model.py: 97%

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.steps if model.steps > 0 else 0

70

71

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

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

74 return model.movements/model.steps if model.steps > 0 else 0

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.scaleSendInput = {}

130 self.updatedTopicsDict = {}

131 self.updatedPositionDict = {}

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 )

145 # Add regions to Nuts2Eu geospace

146 self.space.add_regions(self.regions)

147

148 #######

149 # Initialize sender agents

150 #######

151

152 # Draw initial geographic positions of agents

153 initSenderGeoDf = createData(

154 population,

155 populationDistribution=populationDistributionData,

156 )

157

158 # Calculate mean of mean distances for each agent.

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

160 meandistances = []

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

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

163 otherAgents = initSenderGeoDf.query("index != @idx").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 )

188

189 # Create random set of initial topic vectors.

190 topics = [

191 tuple(

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

193 ) for x in range(self.population)

194 ]

195

196 # Setup senders

197 for idx, sender in enumerate(senders):

198 # Add to social network

199 self.socialNetwork.add_node(

200 sender.unique_id,

201 numLettersSend=0,

202 numLettersReceived=0,

203 )

204 # Give sender topic

205 sender.topicVec = topics[idx]

206 # Add current topic to dict

207 self.updatedTopicsDict.update(

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

209 )

210 # Set random activation weight

211 if useActivation is True:

212 sender.activationWeight = random.random()

213 # Add sender to its region

214 regionID = [

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

216 ]

217 try:

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

219 except IndexError as exc:

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

221 raise IndexError(text) from exc

222 # Prepopulate positions dict

223 self.updatedPositionDict.update(

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

225 )

226

227 # Create social network

228 if useSocialNetwork is True:

229 for agent in self.agents_by_type[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.agents_by_type[SenderAgent] if x not in closerange]

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_with_data(self) -> None:

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

278 self.step()

279 self.datacollector.collect(self)

280

281 def step(self) -> None:

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

283 self.scaleSendInput.update(

284 **{str(x.unique_id): x.numLettersReceived for x in self.agents_by_type[SenderAgent]},

285 )

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

287 # on potential previouse communication events and the position

288 # based on a previous movement.

289 for agent in self.agents_by_type[SenderAgent]:

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

291 newGeom = self.updatedPositionDict[agent.unique_id]

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

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

294 self.agents_by_type[SenderAgent].shuffle_do("step")

295

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

297 """Run the model for n steps.

298

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

300 This is useful for batch runs accross different

301 parameters.

302 """

303 if self.debug is True:

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

305 self.step()

306 else:

307 for _ in range(n):

308 self.step()

309 self.datacollector.collect(self)