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

123 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-16 09:50 +0200

1import random 

2import numpy as np 

3import networkx as nx 

4 

5import mesa 

6import mesa_geo as mg 

7 

8from scicom.historicalletters.utils import getRegion, getPositionOnLine 

9 

10 

11class SenderAgent(mg.GeoAgent): 

12 """The agent sending letters. 

13  

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

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

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

17 within the moveRange.  

18 

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

20 of all held positions in topic space.  

21 """ 

22 def __init__( 

23 self, unique_id, model, geometry, crs, updateTopic, similarityThreshold, moveRange, letterRange 

24 ): 

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

26 self.region_id = '' 

27 self.activationWeight = 1 

28 # Not implemented: 

29 # The updating is a random walk along a line between receiver and sender. 

30 # The strength of adaption is therefore random. 

31 # self.updateTopic = updateTopic 

32 self.similarityThreshold = similarityThreshold 

33 self.moveRange = moveRange 

34 self.letterRange = letterRange 

35 self.topicLedger = [] 

36 self.numLettersReceived = 0 

37 self.numLettersSend = 0 

38 

39 def move(self, neighbors): 

40 """The agent can randomly move to neighboring positions.""" 

41 if neighbors: 

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

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

44 if move[0] == 1: 

45 self.model.movements += 1 

46 weights = [] 

47 possible_steps = [] 

48 # Weighted random choice to target of moving. 

49 # Strong receivers are more likely targets. 

50 # This is another Polya Urn-like process. 

51 for n in neighbors: 

52 if n != self: 

53 possible_steps.append(n.geometry) 

54 weights.append(n.numLettersReceived) 

55 # Capture cases where no possible steps exist. 

56 if possible_steps: 

57 if sum(weights) > 0: 

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

59 else: 

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

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

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

63 # TODO: Is there a more clever way to find nearby valid regions? 

64 try: 

65 regionID = getRegion(next_position, self.model) 

66 self.model.space.move_sender(self, next_position, regionID) 

67 except IndexError: 

68 if self.model.debug: 

69 print(f"No overlap for {next_position}, aborting movement.") 

70 pass 

71 

72 @property 

73 def has_topic(self): 

74 """Current topic of the agent.""" 

75 return self.topicVec 

76 

77 def has_letter_contacts(self, neighbors=False): 

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

79 

80 Implements the ego-reinforcing by allowing mutliple entries 

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

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

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

84 during model initialization. 

85 

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

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

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

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

90 """ 

91 contacts = [] 

92 # Social contacts  

93 socialNetwork = [x for x in self.model.G.neighbors(self.unique_id)] 

94 scaleSocial = {} 

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

96 if y != 0: 

97 scaleSocial.update({x: y}) 

98 else: 

99 scaleSocial.update({x: 1}) 

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

101 contacts.extend(reinforceSocial) 

102 # Geographical neighbors 

103 if neighbors: 

104 neighborRec = [] 

105 for n in neighbors: 

106 if n != self: 

107 if n.numLettersReceived > 0: 

108 nMult = [n] * n.numLettersReceived 

109 neighborRec.extend(nMult) 

110 else: 

111 neighborRec.append(n) 

112 contacts.extend(neighborRec) 

113 return contacts 

114 

115 def chooses_topic(self, receiver): 

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

117 

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

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

120 """ 

121 topicChoices = self.topicLedger.copy() 

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

123 if topicChoices: 

124 initTopic = random.choice(topicChoices) 

125 else: 

126 initTopic = self.topicVec 

127 return initTopic 

128 

129 def sendLetter(self, neighbors): 

130 """Sending a letter based on an urn model.""" 

131 contacts = self.has_letter_contacts(neighbors) 

132 if contacts: 

133 # Randomly choose from the list of possible receivers 

134 receiver = random.choice(contacts) 

135 if isinstance(receiver, SenderAgent) and receiver != self: 

136 initTopic = self.chooses_topic(receiver) 

137 # Calculate distance between own chosen topic  

138 # and current topic of receiver. 

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

140 # If the calculated distance falls below a similarityThreshold, 

141 # send the letter. 

142 if distance < self.similarityThreshold: 

143 receiver.numLettersReceived += 1 

144 self.numLettersSend += 1 

145 # Update model social network 

146 self.model.G.add_edge( 

147 self.unique_id, 

148 receiver.unique_id, 

149 step=self.model.schedule.time 

150 ) 

151 self.model.G.nodes()[self.unique_id]['numLettersSend'] = self.numLettersSend 

152 self.model.G.nodes()[receiver.unique_id]['numLettersReceived'] = receiver.numLettersReceived 

153 # Update receivers topic vector as a random movement 

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

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

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

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

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

159 # If both topics coincide nothing is changing.  

160 start = receiver.topicVec 

161 end = initTopic 

162 if not start == end: 

163 updatedTopicVec = getPositionOnLine(start, end, returnType="coords") 

164 else: 

165 updatedTopicVec = initTopic 

166 # The letter sending process is complet and the chosen topic of the letter is put into a ledger entry. 

167 self.model.letterLedger.append( 

168 ( 

169 self.unique_id, receiver.unique_id, self.region_id, receiver.region_id, 

170 initTopic, self.model.schedule.steps 

171 ) 

172 ) 

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

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

175 # agent's currently held topic positions.  

176 self.model.updatedTopicsDict.update( 

177 {receiver.unique_id: updatedTopicVec} 

178 ) 

179 self.model.updatedTopic += 1 

180 

181 def step(self): 

182 self.topicVec = self.model.updatedTopicsDict[self.unique_id] 

183 self.topicLedger.append( 

184 self.topicVec 

185 ) 

186 currentActivation = random.choices( 

187 population=[0, 1], 

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

189 k=1 

190 ) 

191 if currentActivation[0] == 1: 

192 neighborsMove = [ 

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

194 self, 

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

196 center=False 

197 ) if isinstance(x, SenderAgent) 

198 ] 

199 neighborsSend = [ 

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

201 self, 

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

203 center=False 

204 ) if isinstance(x, SenderAgent) 

205 ] 

206 self.sendLetter(neighborsSend) 

207 self.move(neighborsMove) 

208 

209 

210class RegionAgent(mg.GeoAgent): 

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

212  

213 This agent type is introduced for visualization purposes. 

214 SenderAgents are linked to regions by calculation of a  

215 geographic overlap of the region shape with the SenderAgent 

216 position.  

217 At initialization, the regions are populated with SenderAgents 

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

219 their initial topic.  

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

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

222 """ 

223 

224 def __init__(self, unique_id, model, geometry, crs): 

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

226 self.senders_in_region = dict() 

227 

228 def has_main_topic(self): 

229 if len(self.senders_in_region) > 0: 

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

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

232 if sum(total) > 0: 

233 weight = [x / sum(total) for x in total] 

234 else: 

235 weight = [1 / len(topics)] * len(topics) 

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

237 colors_inverse = np.subtract((1, 1, 1), mixed_colors) 

238 return colors_inverse 

239 else: 

240 return (0.5, 0.5, 0.5) 

241 

242 def add_sender(self, sender): 

243 receivedLetters = sender.numLettersReceived 

244 if receivedLetters > 0: 

245 scale = receivedLetters 

246 else: 

247 scale = 1 

248 self.senders_in_region.update( 

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

250 ) 

251 

252 def remove_sender(self, sender): 

253 del self.senders_in_region[sender.unique_id]