Coverage for .tox/p312/lib/python3.10/site-packages/scicom/historicalletters/agents.py: 92%

114 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-05-28 12:02 +0200

1"""The agent classes for HistoricalLetters.""" 

2import random 

3 

4import mesa 

5import mesa_geo as mg 

6import numpy as np 

7import shapely 

8 

9from scicom.historicalletters.utils import getNewTopic, getPositionOnLine, getRegion 

10 

11 

12class SenderAgent(mg.GeoAgent): 

13 """The agent sending letters. 

14 

15 On initialization an agent is places in a geographical coordinate. 

16 Each agent can send letters to other agents within a distance 

17 determined by the letterRange. Agents can also move to new positions 

18 within the moveRange. 

19 

20 Agents keep track of their changing "interest" by having a vector 

21 of all held positions in topic space. 

22 """ 

23 

24 def __init__( 

25 self, 

26 unique_id:str, 

27 model:mesa.Model, 

28 geometry: shapely.geometry.point.Point, 

29 crs:str, 

30 similarityThreshold:float, 

31 moveRange:float, 

32 letterRange:float, 

33 ) -> None: 

34 """Initialize an agent. 

35 

36 With a model, a geometry, crs, 

37 and values for updateTopic, similarityThreshold, moveRange, 

38 and letterRange. 

39 """ 

40 super().__init__(unique_id, model, geometry, crs) 

41 self.region_id = "" 

42 self.activationWeight = 1 

43 self.similarityThreshold = similarityThreshold 

44 self.moveRange = moveRange 

45 self.letterRange = letterRange 

46 self.topicVec = "" 

47 self.topicLedger = [] 

48 self.numLettersReceived = 0 

49 self.numLettersSend = 0 

50 

51 def move(self, neighbors:list) -> None: 

52 """Agent can randomly move to neighboring positions. 

53 

54 Neighbours with a higher number of received letters are 

55 more likely targets of a movement process. The amount of 

56 movement is randomly drawn. 

57 """ 

58 if neighbors: 

59 # Random decision to move or not, weights are 10% moving, 90% staying. 

60 move = random.choices([0, 1], weights=[0.9, 0.1], k=1) 

61 if move[0] == 1: 

62 weights = [] 

63 possible_steps = [] 

64 # Weighted random choice to target of moving. 

65 # Strong receivers are more likely targets. 

66 # This is another Polya Urn-like process. 

67 for n in neighbors: 

68 if n != self: 

69 possible_steps.append(n.geometry) 

70 weights.append(n.numLettersReceived) 

71 # Capture cases where no possible steps exist. 

72 if possible_steps: 

73 if sum(weights) > 0: 

74 lineEndPoint = random.choices(possible_steps, weights, k=1) 

75 else: 

76 lineEndPoint = random.choices(possible_steps, k=1) 

77 next_position = getPositionOnLine(self.geometry, lineEndPoint[0]) 

78 # Capture cases where next position has no overlap with region shapefiles. 

79 # This can e.g. happen when crossing the English channel or the mediteranian 

80 # sea. 

81 # TODO(malte): Is there a more clever way to find nearby valid regions? 

82 try: 

83 regionID = getRegion(next_position, self.model) 

84 self.model.updatedPositionDict.update( 

85 {self.unique_id: [next_position, regionID]}, 

86 ) 

87 self.model.movements += 1 

88 except IndexError: 

89 if self.model.debug is True: 

90 text = f"No overlap for {next_position}, aborting movement." 

91 print(text) 

92 

93 def has_letter_contacts(self, *, neighbors: list = False) -> list: 

94 """List of already established and potential contacts. 

95 

96 Implements the ego-reinforcing by allowing mutliple entries 

97 of the same agent. In neighbourhoods agents are added proportional 

98 to the number of letters they received, thus increasing the reinforcement. 

99 The range of the visible neighborhood is defined by the letterRange parameter 

100 during model initialization. 

101 

102 For neigbors in the social network (which can be long-tie), the same process 

103 applies. Here, at the begining of each step a list of currently valid scalings 

104 is created, see step function in model.py. This prevents updating of 

105 scales during the random activations of agents in one step. 

106 """ 

107 contacts = [] 

108 # Social contacts 

109 socialNetwork = list(self.model.socialNetwork.neighbors(self.unique_id)) 

110 scaleSocial = {} 

111 for x, y in self.model.scaleSendInput.items(): 

112 if y != 0: 

113 scaleSocial.update({x: y}) 

114 else: 

115 scaleSocial.update({x: 1}) 

116 reinforceSocial = [x for y in [[x] * scaleSocial[x] for x in socialNetwork] for x in y] 

117 contacts.extend(reinforceSocial) 

118 # Geographical neighbors 

119 if neighbors: 

120 neighborRec = [] 

121 for n in neighbors: 

122 if n != self: 

123 curID = n.unique_id 

124 if n.numLettersReceived > 0: 

125 nMult = [curID] * n.numLettersReceived 

126 neighborRec.extend(nMult) 

127 else: 

128 neighborRec.append(curID) 

129 contacts.extend(neighborRec) 

130 return contacts 

131 

132 def chooses_topic(self, receiver: str) -> tuple: 

133 """Choose the topic to write about in the letter. 

134 

135 Agents can choose to write a topic from their own ledger or 

136 in relation to the topics of the receiver. The choice is random. 

137 """ 

138 topicChoices = self.topicLedger.copy() 

139 topicChoices.extend(receiver.topicLedger.copy()) 

140 return random.choice(topicChoices) if topicChoices else self.topicVec 

141 

142 def sendLetter(self, neighbors:list) -> None: 

143 """Send a letter based on an urn model.""" 

144 contacts = self.has_letter_contacts(neighbors=neighbors) 

145 if contacts: 

146 # Randomly choose from the list of possible receivers 

147 receiverID = random.choice(contacts) 

148 for agent in self.model.schedule.agents: 

149 if agent.unique_id == receiverID: 

150 receiver = agent 

151 initTopic = self.chooses_topic(receiver) 

152 # Calculate distance between own chosen topic 

153 # and current topic of receiver. 

154 distance = np.linalg.norm(np.array(receiver.topicVec) - np.array(initTopic)) 

155 # If the calculated distance falls below a similarityThreshold, 

156 # send the letter. 

157 if distance < self.similarityThreshold: 

158 receiver.numLettersReceived += 1 

159 self.numLettersSend += 1 

160 # Update model social network 

161 self.model.socialNetwork.add_edge( 

162 self.unique_id, 

163 receiver.unique_id, 

164 step=self.model.schedule.time, 

165 ) 

166 self.model.socialNetwork.nodes()[self.unique_id]["numLettersSend"] = self.numLettersSend 

167 self.model.socialNetwork.nodes()[receiver.unique_id]["numLettersReceived"] = receiver.numLettersReceived 

168 # Update receivers topic vector as a random movement 

169 # in 3D space on the line between receivers current topic 

170 # and the senders chosen topic vectors. An amount of 1 would 

171 # correspond to a complete addaption of the senders chosen topic 

172 # vector by the receiver. An amount of 0 means the 

173 # receiver is not influencend by the sender at all. 

174 # If both topics coincide nothing is changing. 

175 start = receiver.topicVec 

176 end = initTopic 

177 updatedTopicVec = getNewTopic(start, end) if start != end else initTopic 

178 # The letter sending process is complet and 

179 # the chosen topic of the letter is put into a ledger entry. 

180 self.model.letterLedger.append( 

181 ( 

182 self.unique_id, receiver.unique_id, self.region_id, receiver.region_id, 

183 initTopic, self.model.schedule.steps, 

184 ), 

185 ) 

186 # Take note of the influence the letter had on the receiver. 

187 # This information is used in the step function to update all 

188 # agent's currently held topic positions. 

189 self.model.updatedTopicsDict.update( 

190 {receiver.unique_id: updatedTopicVec}, 

191 ) 

192 

193 def step(self) -> None: 

194 """Perform one simulation step.""" 

195 # If the agent has received a letter in the previous step and 

196 # has updated its internal topicVec state, the new topic state is 

197 # appended to the topicLedger 

198 if not self.topicLedger or self.topicVec != self.topicLedger[-1]: 

199 self.topicLedger.append( 

200 self.topicVec, 

201 ) 

202 currentActivation = random.choices( 

203 population=[0, 1], 

204 weights=[1 - self.activationWeight, self.activationWeight], 

205 k=1, 

206 ) 

207 if currentActivation[0] == 1: 

208 neighborsMove = [ 

209 x for x in self.model.space.get_neighbors_within_distance( 

210 self, 

211 distance=self.moveRange * self.model.meandistance, 

212 center=False, 

213 ) if isinstance(x, SenderAgent) 

214 ] 

215 neighborsSend = [ 

216 x for x in self.model.space.get_neighbors_within_distance( 

217 self, 

218 distance=self.letterRange * self.model.meandistance, 

219 center=False, 

220 ) if isinstance(x, SenderAgent) 

221 ] 

222 self.sendLetter(neighborsSend) 

223 self.move(neighborsMove) 

224 

225 

226class RegionAgent(mg.GeoAgent): 

227 """The region keeping track of contained agents. 

228 

229 This agent type is introduced for visualization purposes. 

230 SenderAgents are linked to regions by calculation of a 

231 geographic overlap of the region shape with the SenderAgent 

232 position. 

233 At initialization, the regions are populated with SenderAgents 

234 giving rise to a dictionary of the contained SenderAgent IDs and 

235 their initial topic. 

236 At each movement, the SenderAgent might cross region boundaries. 

237 This reqieres a re-calculation of the potential overlap. 

238 """ 

239 

240 def __init__( 

241 self, 

242 unique_id:str, 

243 model:mesa.Model, 

244 geometry: shapely.geometry.polygon.Polygon, 

245 crs:str, 

246 ) -> None: 

247 """Initialize region with id, model, geometry and crs.""" 

248 super().__init__(unique_id, model, geometry, crs) 

249 self.senders_in_region = {} 

250 self.main_topic:tuple = self.has_main_topic() 

251 

252 def has_main_topic(self) -> tuple: 

253 """Return weighted average topics of agents in region.""" 

254 if len(self.senders_in_region) > 0: 

255 topics = [y[0] for x, y in self.senders_in_region.items()] 

256 total = [y[1] for x, y in self.senders_in_region.items()] 

257 weight = [x / sum(total) for x in total] if sum(total) > 0 else [1 / len(topics)] * len(topics) 

258 mixed_colors = np.sum([np.multiply(weight[i], topics[i]) for i in range(len(topics))], axis=0) 

259 return np.subtract((1, 1, 1), mixed_colors) 

260 return (0.5, 0.5, 0.5) 

261 

262 def add_sender(self, sender: SenderAgent) -> None: 

263 """Add a sender to the region.""" 

264 receivedLetters = sender.numLettersReceived 

265 scale = receivedLetters if receivedLetters else 1 

266 self.senders_in_region.update( 

267 {sender.unique_id: (sender.topicVec, scale)}, 

268 ) 

269 

270 def remove_sender(self, sender: SenderAgent) -> None: 

271 """Remove a sender from the region.""" 

272 del self.senders_in_region[sender.unique_id]