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

114 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-10 16:21 +1200

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 model:mesa.Model, 

27 geometry: shapely.geometry.point.Point, 

28 crs:str, 

29 similarityThreshold:float, 

30 moveRange:float, 

31 letterRange:float, 

32 ) -> None: 

33 """Initialize an agent. 

34 

35 With a model, a geometry, crs, 

36 and values for updateTopic, similarityThreshold, moveRange, 

37 and letterRange. 

38 """ 

39 super().__init__(model, geometry, crs) 

40 self.region_id = "" 

41 self.activationWeight = 1 

42 self.similarityThreshold = similarityThreshold 

43 self.moveRange = moveRange 

44 self.letterRange = letterRange 

45 self.topicVec = "" 

46 self.topicLedger = [] 

47 self.numLettersReceived = 0 

48 self.numLettersSend = 0 

49 

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

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

52 

53 Neighbours with a higher number of received letters are 

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

55 movement is randomly drawn. 

56 """ 

57 if neighbors: 

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

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

60 if move[0] == 1: 

61 weights = [] 

62 possible_steps = [] 

63 # Weighted random choice to target of moving. 

64 # Strong receivers are more likely targets. 

65 # This is another Polya Urn-like process. 

66 for n in neighbors: 

67 if n != self: 

68 possible_steps.append(n.geometry) 

69 weights.append(n.numLettersReceived) 

70 # Capture cases where no possible steps exist. 

71 if possible_steps: 

72 if sum(weights) > 0: 

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

74 else: 

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

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

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

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

79 # sea. 

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

81 try: 

82 regionID = getRegion(next_position, self.model) 

83 self.model.updatedPositionDict.update( 

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

85 ) 

86 self.model.movements += 1 

87 except IndexError: 

88 if self.model.debug is True: 

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

90 print(text) 

91 

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

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

94 

95 Implements the ego-reinforcing by allowing mutliple entries 

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

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

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

99 during model initialization. 

100 

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

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

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

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

105 """ 

106 contacts = [] 

107 # Social contacts 

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

109 scaleSocial = {} 

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

111 if y != 0: 

112 scaleSocial.update({x: y}) 

113 else: 

114 scaleSocial.update({x: 1}) 

115 reinforceSocial = [x for y in [[x] * scaleSocial[str(x)] for x in socialNetwork] for x in y] 

116 contacts.extend(reinforceSocial) 

117 # Geographical neighbors 

118 if neighbors: 

119 neighborRec = [] 

120 for n in neighbors: 

121 if n != self: 

122 curID = n.unique_id 

123 if n.numLettersReceived > 0: 

124 nMult = [curID] * n.numLettersReceived 

125 neighborRec.extend(nMult) 

126 else: 

127 neighborRec.append(curID) 

128 contacts.extend(neighborRec) 

129 return contacts 

130 

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

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

133 

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

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

136 """ 

137 topicChoices = self.topicLedger.copy() 

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

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

140 

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

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

143 contacts = self.has_letter_contacts(neighbors=neighbors) 

144 if contacts: 

145 # Randomly choose from the list of possible receivers 

146 receiverID = random.choice(contacts) 

147 for agent in self.model.agents_by_type[SenderAgent]: 

148 if agent.unique_id == receiverID: 

149 receiver = agent 

150 initTopic = self.chooses_topic(receiver) 

151 # Calculate distance between own chosen topic 

152 # and current topic of receiver. 

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

154 # If the calculated distance falls below a similarityThreshold, 

155 # send the letter. 

156 if distance < self.similarityThreshold: 

157 receiver.numLettersReceived += 1 

158 self.numLettersSend += 1 

159 # Update model social network 

160 self.model.socialNetwork.add_edge( 

161 self.unique_id, 

162 receiver.unique_id, 

163 step=self.model.steps, 

164 ) 

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

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

167 # Update receivers topic vector as a random movement 

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

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

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

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

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

173 # If both topics coincide nothing is changing. 

174 start = receiver.topicVec 

175 end = initTopic 

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

177 # The letter sending process is complet and 

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

179 self.model.letterLedger.append( 

180 ( 

181 self.unique_id, receiver.unique_id, self.region_id, receiver.region_id, 

182 initTopic, self.model.steps, 

183 ), 

184 ) 

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

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

187 # agent's currently held topic positions. 

188 self.model.updatedTopicsDict.update( 

189 {receiver.unique_id: updatedTopicVec}, 

190 ) 

191 

192 def step(self) -> None: 

193 """Perform one simulation step.""" 

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

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

196 # appended to the topicLedger 

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

198 self.topicLedger.append( 

199 self.topicVec, 

200 ) 

201 currentActivation = random.choices( 

202 population=[0, 1], 

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

204 k=1, 

205 ) 

206 if currentActivation[0] == 1: 

207 neighborsMove = [ 

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

209 self, 

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

211 center=False, 

212 ) if isinstance(x, SenderAgent) 

213 ] 

214 neighborsSend = [ 

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

216 self, 

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

218 center=False, 

219 ) if isinstance(x, SenderAgent) 

220 ] 

221 self.sendLetter(neighborsSend) 

222 self.move(neighborsMove) 

223 

224 

225class RegionAgent(mg.GeoAgent): 

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

227 

228 This agent type is introduced for visualization purposes. 

229 SenderAgents are linked to regions by calculation of a 

230 geographic overlap of the region shape with the SenderAgent 

231 position. 

232 At initialization, the regions are populated with SenderAgents 

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

234 their initial topic. 

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

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

237 """ 

238 

239 def __init__( 

240 self, 

241 model:mesa.Model, 

242 geometry: shapely.geometry.polygon.Polygon, 

243 crs:str, 

244 ) -> None: 

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

246 super().__init__(model, geometry, crs) 

247 self.senders_in_region = {} 

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

249 

250 def has_main_topic(self) -> tuple: 

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

252 if len(self.senders_in_region) > 0: 

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

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

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

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

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

258 return (0.5, 0.5, 0.5) 

259 

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

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

262 receivedLetters = sender.numLettersReceived 

263 scale = receivedLetters if receivedLetters else 1 

264 self.senders_in_region.update( 

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

266 ) 

267 

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

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

270 del self.senders_in_region[sender.unique_id]