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

123 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-15 13:26 +0200

1import random 

2import numpy as np 

3import networkx as nx 

4#from shapely.geometry import LineString 

5#from sympy import Point3D, Line3D 

6#from sympy.abc import t 

7 

8import mesa 

9import mesa_geo as mg 

10 

11from scicom.historicalletters.utils import getRegion, getPositionOnLine 

12 

13 

14class SenderAgent(mg.GeoAgent): 

15 """The agent sending letters. 

16  

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

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

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

20 within the moveRange.  

21 

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

23 of all held positions in topic space.  

24 """ 

25 def __init__( 

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

27 ): 

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

29 self.region_id = '' 

30 self.activationWeight = 1 

31 # Not implemented: 

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

33 # The strength of adaption is therefore random. 

34 # self.updateTopic = updateTopic 

35 self.similarityThreshold = similarityThreshold 

36 self.moveRange = moveRange 

37 self.letterRange = letterRange 

38 self.topicLedger = [] 

39 self.numLettersReceived = 0 

40 self.numLettersSend = 0 

41 

42 def move(self, neighbors): 

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

44 if neighbors: 

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

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

47 if move[0] == 1: 

48 self.model.movements += 1 

49 weights = [] 

50 possible_steps = [] 

51 # Weighted random choice to target of moving. 

52 # Strong receivers are more likely targets. 

53 # This is another Polya Urn-like process. 

54 for n in neighbors: 

55 if n != self: 

56 possible_steps.append(n.geometry) 

57 weights.append(n.numLettersReceived) 

58 # Capture cases where no possible steps exist. 

59 if possible_steps: 

60 if sum(weights) > 0: 

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

62 else: 

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

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

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

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

67 try: 

68 regionID = getRegion(next_position, self.model) 

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

70 except IndexError: 

71 if self.model.debug: 

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

73 pass 

74 

75 

76 @property 

77 def has_topic(self): 

78 """Current topic of the agent.""" 

79 return self.topicVec 

80 

81 def has_letter_contacts(self, neighbors=False): 

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

83 

84 Implements the ego-reinforcing by allowing mutliple entries 

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

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

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

88 during model initialization. 

89 

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

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

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

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

94 """ 

95 contacts = [] 

96 # Social contacts  

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

98 scaleSocial = {} 

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

100 if y != 0: 

101 scaleSocial.update({x: y}) 

102 else: 

103 scaleSocial.update({x: 1}) 

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

105 contacts.extend(reinforceSocial) 

106 # Geographical neighbors 

107 if neighbors: 

108 neighborRec = [] 

109 for n in neighbors: 

110 if n != self: 

111 if n.numLettersReceived > 0: 

112 nMult = [n] * n.numLettersReceived 

113 neighborRec.extend(nMult) 

114 else: 

115 neighborRec.append(n) 

116 contacts.extend(neighborRec) 

117 return contacts 

118 

119 def chooses_topic(self, receiver): 

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

121 

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

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

124 """ 

125 topicChoices = self.topicLedger.copy() 

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

127 if topicChoices: 

128 initTopic = random.choice(topicChoices) 

129 else: 

130 initTopic = self.topicVec 

131 return initTopic 

132 

133 def sendLetter(self, neighbors): 

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

135 contacts = self.has_letter_contacts(neighbors) 

136 if contacts: 

137 # Randomly choose from the list of possible receivers 

138 receiver = random.choice(contacts) 

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

140 initTopic = self.chooses_topic(receiver) 

141 # Calculate distance between own chosen topic  

142 # and current topic of receiver. 

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

144 # If the calculated distance falls below a similarityThreshold, 

145 # send the letter. 

146 if distance < self.similarityThreshold: 

147 receiver.numLettersReceived += 1 

148 self.numLettersSend += 1 

149 # Update model social network 

150 self.model.G.add_edge( 

151 self.unique_id, 

152 receiver.unique_id, 

153 step=self.model.schedule.time 

154 ) 

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

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

157 # Update receivers topic vector as a random movement 

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

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

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

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

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

163 # If both topics coincide nothing is changing.  

164 start = receiver.topicVec 

165 end = initTopic 

166 if not start == end: 

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

168 else: 

169 updatedTopicVec = initTopic 

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

171 self.model.letterLedger.append( 

172 ( 

173 self.unique_id, receiver.unique_id, self.region_id, receiver.region_id, 

174 initTopic, self.model.schedule.steps 

175 ) 

176 ) 

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

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

179 # agent's currently held topic positions.  

180 self.model.updatedTopicsDict.update( 

181 {receiver.unique_id: updatedTopicVec} 

182 ) 

183 self.model.updatedTopic += 1 

184 

185 def step(self): 

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

187 self.topicLedger.append( 

188 self.topicVec 

189 ) 

190 currentActivation = random.choices( 

191 population=[0, 1], 

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

193 k=1 

194 ) 

195 if currentActivation[0] == 1: 

196 neighborsMove = [ 

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

198 self, 

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

200 center=False 

201 ) if isinstance(x, SenderAgent) 

202 ] 

203 neighborsSend = [ 

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

205 self, 

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

207 center=False 

208 ) if isinstance(x, SenderAgent) 

209 ] 

210 self.sendLetter(neighborsSend) 

211 self.move(neighborsMove) 

212 

213 

214class RegionAgent(mg.GeoAgent): 

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

216  

217 This agent type is introduced for visualization purposes. 

218 SenderAgents are linked to regions by calculation of a  

219 geographic overlap of the region shape with the SenderAgent 

220 position.  

221 At initialization, the regions are populated with SenderAgents 

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

223 their initial topic.  

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

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

226 """ 

227 

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

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

230 self.senders_in_region = dict() 

231 

232 def has_main_topic(self): 

233 if len(self.senders_in_region) > 0: 

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

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

236 if sum(total) > 0: 

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

238 else: 

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

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

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

242 return colors_inverse 

243 else: 

244 return (0.5, 0.5, 0.5) 

245 

246 def add_sender(self, sender): 

247 receivedLetters = sender.numLettersReceived 

248 if receivedLetters > 0: 

249 scale = receivedLetters 

250 else: 

251 scale = 1 

252 self.senders_in_region.update( 

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

254 ) 

255 

256 def remove_sender(self, sender): 

257 del self.senders_in_region[sender.unique_id]