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

115 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-05-15 12:29 +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 if neighbors: 

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

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

56 if move[0] == 1: 

57 self.model.movements += 1 

58 weights = [] 

59 possible_steps = [] 

60 # Weighted random choice to target of moving. 

61 # Strong receivers are more likely targets. 

62 # This is another Polya Urn-like process. 

63 for n in neighbors: 

64 if n != self: 

65 possible_steps.append(n.geometry) 

66 weights.append(n.numLettersReceived) 

67 # Capture cases where no possible steps exist. 

68 if possible_steps: 

69 if sum(weights) > 0: 

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

71 else: 

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

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

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

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

76 try: 

77 regionID = getRegion(next_position, self.model) 

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

79 except IndexError: 

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

81 print(text) 

82 

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

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

85 

86 Implements the ego-reinforcing by allowing mutliple entries 

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

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

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

90 during model initialization. 

91 

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

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

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

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

96 """ 

97 contacts = [] 

98 # Social contacts 

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

100 scaleSocial = {} 

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

102 if y != 0: 

103 scaleSocial.update({x: y}) 

104 else: 

105 scaleSocial.update({x: 1}) 

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

107 contacts.extend(reinforceSocial) 

108 # Geographical neighbors 

109 if neighbors: 

110 neighborRec = [] 

111 for n in neighbors: 

112 if n != self: 

113 curID = n.unique_id 

114 if n.numLettersReceived > 0: 

115 nMult = [curID] * n.numLettersReceived 

116 neighborRec.extend(nMult) 

117 else: 

118 neighborRec.append(curID) 

119 contacts.extend(neighborRec) 

120 return contacts 

121 

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

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

124 

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

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

127 """ 

128 topicChoices = self.topicLedger.copy() 

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

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

131 

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

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

134 contacts = self.has_letter_contacts(neighbors=neighbors) 

135 if contacts: 

136 # Randomly choose from the list of possible receivers 

137 receiverID = random.choice(contacts) 

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

139 if agent.unique_id == receiverID: 

140 receiver = agent 

141 initTopic = self.chooses_topic(receiver) 

142 # Calculate distance between own chosen topic 

143 # and current topic of receiver. 

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

145 # If the calculated distance falls below a similarityThreshold, 

146 # send the letter. 

147 if distance < self.similarityThreshold: 

148 receiver.numLettersReceived += 1 

149 self.numLettersSend += 1 

150 # Update model social network 

151 self.model.socialNetwork.add_edge( 

152 self.unique_id, 

153 receiver.unique_id, 

154 step=self.model.schedule.time, 

155 ) 

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

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

158 # Update receivers topic vector as a random movement 

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

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

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

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

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

164 # If both topics coincide nothing is changing. 

165 start = receiver.topicVec 

166 end = initTopic 

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

168 # The letter sending process is complet and 

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

170 self.model.letterLedger.append( 

171 ( 

172 self.unique_id, receiver.unique_id, self.region_id, receiver.region_id, 

173 initTopic, self.model.schedule.steps, 

174 ), 

175 ) 

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

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

178 # agent's currently held topic positions. 

179 self.model.updatedTopicsDict.update( 

180 {receiver.unique_id: updatedTopicVec}, 

181 ) 

182 

183 def step(self) -> None: 

184 """Perform one simulation step.""" 

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

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

187 # appended to the topicLedger 

188 if not self.topicLedger: 

189 self.topicLedger.append( 

190 self.topicVec, 

191 ) 

192 elif self.topicVec != self.topicLedger[-1]: 

193 self.topicLedger.append( 

194 self.topicVec, 

195 ) 

196 currentActivation = random.choices( 

197 population=[0, 1], 

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

199 k=1, 

200 ) 

201 if currentActivation[0] == 1: 

202 neighborsMove = [ 

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

204 self, 

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

206 center=False, 

207 ) if isinstance(x, SenderAgent) 

208 ] 

209 neighborsSend = [ 

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

211 self, 

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

213 center=False, 

214 ) if isinstance(x, SenderAgent) 

215 ] 

216 self.sendLetter(neighborsSend) 

217 self.move(neighborsMove) 

218 

219 

220class RegionAgent(mg.GeoAgent): 

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

222 

223 This agent type is introduced for visualization purposes. 

224 SenderAgents are linked to regions by calculation of a 

225 geographic overlap of the region shape with the SenderAgent 

226 position. 

227 At initialization, the regions are populated with SenderAgents 

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

229 their initial topic. 

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

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

232 """ 

233 

234 def __init__( 

235 self, 

236 unique_id:str, 

237 model:mesa.Model, 

238 geometry: shapely.geometry.polygon.Polygon, 

239 crs:str, 

240 ) -> None: 

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

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

243 self.senders_in_region = {} 

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

245 

246 def has_main_topic(self) -> tuple: 

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

248 if len(self.senders_in_region) > 0: 

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

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

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

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

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

254 return (0.5, 0.5, 0.5) 

255 

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

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

258 receivedLetters = sender.numLettersReceived 

259 scale = receivedLetters if receivedLetters else 1 

260 self.senders_in_region.update( 

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

262 ) 

263 

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

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

266 del self.senders_in_region[sender.unique_id]