Coverage for .tox/p311/lib/python3.10/site-packages/scicom/historicalletters/agents.py: 93%
116 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-26 18:34 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-26 18:34 +0200
1"""The agent classes for HistoricalLetters."""
2import random
4import mesa
5import mesa_geo as mg
6import numpy as np
7import shapely
9from scicom.historicalletters.utils import getNewTopic, getPositionOnLine, getRegion
12class SenderAgent(mg.GeoAgent):
13 """The agent sending letters.
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.
20 Agents keep track of their changing "interest" by having a vector
21 of all held positions in topic space.
22 """
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.
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
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)
83 def has_letter_contacts(self, *, neighbors: list = False) -> list:
84 """List of already established and potential contacts.
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.
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
122 def chooses_topic(self, receiver: str) -> tuple:
123 """Choose the topic to write about in the letter.
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
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 )
183 def step(self) -> None:
184 """Perform one simulation step."""
185 self.topicVec = self.model.updatedTopicsDict[self.unique_id]
186 # If the agent has received a letter in the previous step and
187 # has updated its internal topicVec state, the new topic state is
188 # appended to the topicLedger
189 if not self.topicLedger:
190 self.topicLedger.append(
191 self.topicVec,
192 )
193 elif self.topicVec != self.topicLedger[-1]:
194 self.topicLedger.append(
195 self.topicVec,
196 )
197 currentActivation = random.choices(
198 population=[0, 1],
199 weights=[1 - self.activationWeight, self.activationWeight],
200 k=1,
201 )
202 if currentActivation[0] == 1:
203 neighborsMove = [
204 x for x in self.model.space.get_neighbors_within_distance(
205 self,
206 distance=self.moveRange * self.model.meandistance,
207 center=False,
208 ) if isinstance(x, SenderAgent)
209 ]
210 neighborsSend = [
211 x for x in self.model.space.get_neighbors_within_distance(
212 self,
213 distance=self.letterRange * self.model.meandistance,
214 center=False,
215 ) if isinstance(x, SenderAgent)
216 ]
217 self.sendLetter(neighborsSend)
218 self.move(neighborsMove)
221class RegionAgent(mg.GeoAgent):
222 """The region keeping track of contained agents.
224 This agent type is introduced for visualization purposes.
225 SenderAgents are linked to regions by calculation of a
226 geographic overlap of the region shape with the SenderAgent
227 position.
228 At initialization, the regions are populated with SenderAgents
229 giving rise to a dictionary of the contained SenderAgent IDs and
230 their initial topic.
231 At each movement, the SenderAgent might cross region boundaries.
232 This reqieres a re-calculation of the potential overlap.
233 """
235 def __init__(
236 self,
237 unique_id:str,
238 model:mesa.Model,
239 geometry: shapely.geometry.polygon.Polygon,
240 crs:str,
241 ) -> None:
242 """Initialize region with id, model, geometry and crs."""
243 super().__init__(unique_id, model, geometry, crs)
244 self.senders_in_region = {}
245 self.main_topic:tuple = self.has_main_topic()
247 def has_main_topic(self) -> tuple:
248 """Return weighted average topics of agents in region."""
249 if len(self.senders_in_region) > 0:
250 topics = [y[0] for x, y in self.senders_in_region.items()]
251 total = [y[1] for x, y in self.senders_in_region.items()]
252 weight = [x / sum(total) for x in total] if sum(total) > 0 else [1 / len(topics)] * len(topics)
253 mixed_colors = np.sum([np.multiply(weight[i], topics[i]) for i in range(len(topics))], axis=0)
254 return np.subtract((1, 1, 1), mixed_colors)
255 return (0.5, 0.5, 0.5)
257 def add_sender(self, sender: SenderAgent) -> None:
258 """Add a sender to the region."""
259 receivedLetters = sender.numLettersReceived
260 scale = receivedLetters if receivedLetters else 1
261 self.senders_in_region.update(
262 {sender.unique_id: (sender.topicVec, scale)},
263 )
265 def remove_sender(self, sender: SenderAgent) -> None:
266 """Remove a sender from the region."""
267 del self.senders_in_region[sender.unique_id]