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

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

2 import random

3

4 import mesa

5 import mesa_geo as mg

6 import numpy as np

7 import shapely

8

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

10

11

12 class 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

226 class 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]