Coverage for .tox/p312/lib/python3.10/site-packages/scicom/historicalletters/agents.py: 92%
114 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-05-28 12:02 +0200
« prev ^ index » next coverage.py v7.4.4, created at 2024-05-28 12:02 +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.
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)
93 def has_letter_contacts(self, *, neighbors: list = False) -> list:
94 """List of already established and potential contacts.
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.
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
132 def chooses_topic(self, receiver: str) -> tuple:
133 """Choose the topic to write about in the letter.
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
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 )
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)
226class RegionAgent(mg.GeoAgent):
227 """The region keeping track of contained agents.
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 """
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()
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)
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 )
270 def remove_sender(self, sender: SenderAgent) -> None:
271 """Remove a sender from the region."""
272 del self.senders_in_region[sender.unique_id]