Coverage for src/scicom/historicalletters/model.py: 0%

1

2 import random

3 from pathlib import Path

4#from statistics import mean

5 from tqdm import tqdm

6 from numpy import mean

7

8 import mesa

9 import networkx as nx

10 import mesa_geo as mg

11 from shapely import contains

12

13 from scicom.utilities.statistics import prune

14 from scicom.historicalletters.utils import createData

15

16 from scicom.historicalletters.agents import SenderAgent, RegionAgent

17 from scicom.historicalletters.space import Nuts2Eu

18

19

20 def getPrunedLedger(model):

21 """Model reporter for simulation of archiving.

22

23 Returns statistics of ledger network of model run

24 and various iterations of statistics of pruned networks.

25 """

26 # TODO: Add all model params

27 if model.runPruning is True:

28 ledgerColumns = ['sender', 'receiver', 'sender_location', 'receiver_location', 'topic', 'step']

29 modelparams = {

30 "population": model.population,

31 "moveRange": model.moveRange,

32 "letterRange": model.letterRange,

33 "useActivation": model.useActivation,

34 "useSocialNetwork": model.useSocialNetwork,

35 }

36 result = prune(

37 modelparameters=modelparams,

38 network=model.letterLedger,

39 columns=ledgerColumns

40 )

41 else:

42 result = model.letterLedger

43 return result

44

45 def getComponents(model):

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.G.to_undirected(reciprocal=True)

54 comp = nx.number_connected_components(newG)

55 return comp

56

57

58 class HistoricalLetters(mesa.Model):

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

60

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

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

63 based on data from [1]

64

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

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

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

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

69

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

71 PLoS ONE 11(10): e0162678.

72 """

73 def __init__(

74 self,

75 population: int = 100,

76 moveRange: float = 0.05,

77 letterRange: float = 0.2,

78 similarityThreshold: float = 0.2,

79 updateTopic: float = 0.1,

80 useActivation=False,

81 useSocialNetwork=False,

82 longRangeNetworkFactor=0.3,

83 shortRangeNetworkFactor=0.8,

84 runPruning=False,

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

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

87 tempfolder: str = "./",

88 debug=False

89 ):

90 super().__init__()

91

92 # Control variables

93 self.population = population

94 self.moveRange = moveRange

95 self.letterRange = letterRange

96 self.useActivation = useActivation

97 # Initialize social network

98 self.useSocialNetwork = useSocialNetwork

99 self.G = nx.MultiDiGraph()

100 self.longRangeNetworkFactor = longRangeNetworkFactor

101 self.shortRangeNetworkFactor = shortRangeNetworkFactor

102 # Collected output variables

103 self.letterLedger = []

104 self.runPruning = runPruning

105 self.movements = 0

106 self.updatedTopic = 0

107 # Internal variables

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

109 self.scaleSendInput = {}

110 self.updatedTopicsDict = {}

111 self.space = Nuts2Eu()

112 self.personRegionMap = {}

113 self.tempfolder = tempfolder

114 self.debug = debug

115

116 initSenderGeoDf = createData(

117 population,

118 populationDistribution=populationDistributionData

119 )

120

121 # Calculate mean of mean distances for each agent.

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

123 distances = []

124 for idx, row in initSenderGeoDf.iterrows():

125 p1 = row['geometry']

126 distances.append(

127 initSenderGeoDf.geometry.apply(lambda x: p1.distance(x)).mean()

128 )

129 self.meandistance = mean(distances)

130

131 self.factors = dict(

132 updateTopic=updateTopic,

133 similarityThreshold=similarityThreshold,

134 moveRange=moveRange,

135 letterRange=letterRange,

136 )

137

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

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

140 self.regions = ac.from_file(

141 regionData,

142 unique_id="NUTS_ID"

143 )

144 self.space.add_regions(self.regions)

145

146 # Set up agent creator for senders

147 ac_senders = mg.AgentCreator(

148 SenderAgent,

149 model=self,

150 agent_kwargs=self.factors

151 )

152

153 # Create agents based on random coordinates generated

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

155 senders = ac_senders.from_GeoDataFrame(

156 initSenderGeoDf,

157 unique_id="unique_id"

158 )

159

160 # Create random set of initial topic vectors.

161 topics = [

162 tuple(

163 [random.random() for x in range(3)]

164 ) for x in range(self.population)

165 ]

166

167 # Attach topic and activationWeight to each agent,

168 # connect to social network graph.

169 for idx, sender in enumerate(senders):

170 self.G.add_node(

171 sender.unique_id,

172 numLettersSend=0,

173 numLettersReceived=0

174 )

175 sender.topicVec = topics[idx]

176 # Add current topic to dict

177 self.updatedTopicsDict.update(

178 {sender.unique_id: topics[idx]}

179 )

180 if useActivation is True:

181 sender.activationWeight = random.random()

182

183 for agent in senders:

184 regionID = [

185 x.unique_id for x in self.regions if contains(x.geometry, agent.geometry)

186 ]

187 try:

188 self.space.add_sender(agent, regionID[0])

189 except IndexError:

190 raise IndexError(f"Problem finding region for {agent.geometry}.")

191 self.schedule.add(agent)

192

193 # Add graph to network grid for potential visualization.

194 # TODO: Not yet implemented. Maybe use Solara backend for this?

195 self.grid = mesa.space.NetworkGrid(self.G)

196

197 # Create social network

198 if useSocialNetwork is True:

199 for agent in self.schedule.agents:

200 if isinstance(agent, SenderAgent):

201 self._createSocialEdges(agent, self.G)

202

203 # TODO: What comparitive values are useful for visualizations?

204 self.datacollector = mesa.DataCollector(

205 model_reporters={

206 "Ledger": getPrunedLedger,

207 "Letters": lambda x: len(self.letterLedger),

208 "Movements": lambda x: self.movements,

209 "Clusters": getComponents

210 },

211 )

212

213 def _createSocialEdges(self, agent, graph):

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

215

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

217 these neighbors, create a connection with probability set by

218 the shortRangeNetworkFactor.

219

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

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

222 """

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

224 agent,

225 distance=self.moveRange * self.meandistance,

226 center=False

227 ) if isinstance(x, SenderAgent)]

228 for neighbor in closerange:

229 if neighbor.unique_id != agent.unique_id:

230 connect = random.choices(

231 population=[True, False],

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

233 k=1

234 )

235 if connect[0] is True:

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

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

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

239 for neighbor in longrange:

240 if neighbor.unique_id != agent.unique_id:

241 connect = random.choices(

242 population=[True, False],

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

244 k=1

245 )

246 if connect[0] is True:

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

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

249

250 def step(self):

251 self.scaleSendInput.update(

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

253 )

254 self.schedule.step()

255 self.datacollector.collect(self)

256

257 def run(self, n):

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

259 if self.debug is True:

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

261 self.step()

262 else:

263 for _ in range(n):

264 self.step()