Coverage for .tox/p311/lib/python3.10/site-packages/scicom/historicalletters/model.py: 94%
108 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-26 14:26 +0200
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-26 14:26 +0200
1"""The model class for HistoricalLetters."""
2import random
3from pathlib import Path
5import mesa
6import mesa_geo as mg
7import networkx as nx
8import pandas as pd
9from numpy import mean
10from shapely import contains
11from tqdm import tqdm
13from scicom.historicalletters.agents import RegionAgent, SenderAgent
14from scicom.historicalletters.space import Nuts2Eu
15from scicom.historicalletters.utils import createData
16from scicom.utilities.statistics import prune
19def getPrunedLedger(model: mesa.Model) -> pd.DataFrame:
20 """Model reporter for simulation of archiving.
22 Returns statistics of ledger network of model run
23 and various iterations of statistics of pruned networks.
24 """
25 # TODO(malte): Add all model params
26 if model.runPruning is True:
27 ledgerColumns = ["sender", "receiver", "sender_location", "receiver_location", "topic", "step"]
28 modelparams = {
29 "population": model.population,
30 "moveRange": model.moveRange,
31 "letterRange": model.letterRange,
32 "useActivation": model.useActivation,
33 "useSocialNetwork": model.useSocialNetwork,
34 }
35 result = prune(
36 modelparameters=modelparams,
37 network=model.letterLedger,
38 columns=ledgerColumns,
39 )
40 else:
41 result = model.letterLedger
42 return result
45def getComponents(model: mesa.Model) -> int:
46 """Model reporter to get number of components.
48 The MultiDiGraph is converted to undirected,
49 considering only edges that are reciprocal, ie.
50 edges are established if sender and receiver have
51 exchanged at least a letter in each direction.
52 """
53 newg = model.socialNetwork.to_undirected(reciprocal=True)
54 return nx.number_connected_components(newg)
57def getScaledLetters(model: mesa.Model) -> float:
58 """Return relative number of send letters."""
59 return len(model.letterLedger)/model.schedule.time
62def getScaledMovements(model: mesa.Model) -> float:
63 """Return relative number of movements."""
64 return model.movements/model.schedule.time
67class HistoricalLetters(mesa.Model):
68 """A letter sending model with historical informed initital positions.
70 Each agent has an initial topic vector, expressed as a RGB value. The
71 initial positions of the agents is based on a weighted random draw
72 based on data from [1].
74 Each step, agents generate two neighbourhoods for sending letters and
75 potential targets to move towards. The probability to send letters is
76 a self-reinforcing process. During each sending the internal topic of
77 the sender is updated as a random rotation towards the receivers topic.
79 [1] J. Lobo et al, Population-Area Relationship for Medieval European Cities,
80 PLoS ONE 11(10): e0162678.
81 """
83 def __init__(
84 self,
85 population: int = 100,
86 moveRange: float = 0.05,
87 letterRange: float = 0.2,
88 similarityThreshold: float = 0.2,
89 longRangeNetworkFactor: float = 0.3,
90 shortRangeNetworkFactor: float = 0.4,
91 regionData: str = Path(Path(__file__).parent.parent.resolve(), "data/NUTS_RG_60M_2021_3857_LEVL_2.geojson"),
92 populationDistributionData: str = Path(Path(__file__).parent.parent.resolve(), "data/pone.0162678.s003.csv"),
93 *,
94 useActivation: bool = False,
95 useSocialNetwork: bool = False,
96 runPruning: bool = False,
97 debug: bool = False,
98 ) -> None:
99 """Initialize a HistoricalLetters model."""
100 super().__init__()
102 # Parameters for agents
103 self.population = population
104 self.moveRange = moveRange
105 self.letterRange = letterRange
106 # Parameters for model
107 self.runPruning = runPruning
108 self.useActivation = useActivation
109 self.useSocialNetwork = useSocialNetwork
110 self.longRangeNetworkFactor = longRangeNetworkFactor
111 self.shortRangeNetworkFactor = shortRangeNetworkFactor
112 # Initialize social network
113 self.socialNetwork = nx.MultiDiGraph()
114 # Output variables
115 self.letterLedger = []
116 self.movements = 0
117 # Internal variables
118 self.schedule = mesa.time.RandomActivation(self)
119 self.scaleSendInput = {}
120 self.updatedTopicsDict = {}
121 self.space = Nuts2Eu()
122 self.debug = debug
124 #######
125 # Initialize region agents
126 #######
128 # Set up the grid with patches for every NUTS region
129 # Create region agents
130 ac = mg.AgentCreator(RegionAgent, model=self)
131 self.regions = ac.from_file(
132 regionData,
133 unique_id="NUTS_ID",
134 )
135 # Add regions to Nuts2Eu geospace
136 self.space.add_regions(self.regions)
138 #######
139 # Initialize sender agents
140 #######
142 # Draw initial geographic positions of agents
143 initSenderGeoDf = createData(
144 population,
145 populationDistribution=populationDistributionData,
146 )
148 # Calculate mean of mean distances for each agent.
149 # This is used as a measure for the range of exchanges.
150 meandistances = []
151 for idx in initSenderGeoDf.index.to_numpy():
152 name = initSenderGeoDf.loc[idx, "unique_id"]
153 geom = initSenderGeoDf.loc[idx, "geometry"]
154 otherAgents = initSenderGeoDf.query(f"unique_id != '{name}'").copy()
155 geometries = otherAgents.geometry.to_numpy()
156 distances = [geom.distance(othergeom) for othergeom in geometries]
157 meandistances.append(mean(distances))
158 self.meandistance = mean(meandistances)
160 # Populate factors dictionary
161 self.factors = {
162 "similarityThreshold": similarityThreshold,
163 "moveRange": moveRange,
164 "letterRange": letterRange,
165 }
167 # Set up agent creator for senders
168 ac_senders = mg.AgentCreator(
169 SenderAgent,
170 model=self,
171 agent_kwargs=self.factors,
172 )
174 # Create agents based on random coordinates generated
175 # in the createData step above, see util.py file.
176 senders = ac_senders.from_GeoDataFrame(
177 initSenderGeoDf,
178 unique_id="unique_id",
179 )
181 # Create random set of initial topic vectors.
182 topics = [
183 tuple(
184 [random.random() for x in range(3)],
185 ) for x in range(self.population)
186 ]
188 # Setup senders
189 for idx, sender in enumerate(senders):
190 # Add to social network
191 self.socialNetwork.add_node(
192 sender.unique_id,
193 numLettersSend=0,
194 numLettersReceived=0,
195 )
196 # Give sender topic
197 sender.topicVec = topics[idx]
198 # Add current topic to dict
199 self.updatedTopicsDict.update(
200 {sender.unique_id: topics[idx]},
201 )
202 # Set random activation weight
203 if useActivation is True:
204 sender.activationWeight = random.random()
205 # Add sender to its region
206 regionID = [
207 x.unique_id for x in self.regions if contains(x.geometry, sender.geometry)
208 ]
209 try:
210 self.space.add_sender(sender, regionID[0])
211 except IndexError as exc:
212 text = f"Problem finding region for {sender.geometry}."
213 raise IndexError(text) from exc
214 # Add sender to schedule
215 self.schedule.add(sender)
217 # Add graph to network grid for potential visualization.
218 # TODO(malte): Not yet implemented. Maybe use Solara backend for this?
219 # self.grid = mesa.space.NetworkGrid(self.socialNetwork)
221 # Create social network
222 if useSocialNetwork is True:
223 for agent in self.schedule.agents:
224 if isinstance(agent, SenderAgent):
225 self._createSocialEdges(agent, self.socialNetwork)
227 self.datacollector = mesa.DataCollector(
228 model_reporters={
229 "Ledger": getPrunedLedger,
230 "Letters": getScaledLetters ,
231 "Movements": getScaledMovements,
232 "Clusters": getComponents,
233 },
234 )
236 def _createSocialEdges(self, agent: SenderAgent, graph: nx.MultiDiGraph) -> None:
237 """Create social edges with the different wiring factors.
239 Define a close range by using the moveRange parameter. Among
240 these neighbors, create a connection with probability set by
241 the shortRangeNetworkFactor.
243 For all other agents, that are not in this closeRange group,
244 create a connection with the probability set by the longRangeNetworkFactor.
245 """
246 closerange = [x for x in self.space.get_neighbors_within_distance(
247 agent,
248 distance=self.moveRange * self.meandistance,
249 center=False,
250 ) if isinstance(x, SenderAgent)]
251 for neighbor in closerange:
252 if neighbor.unique_id != agent.unique_id:
253 connect = random.choices(
254 population=[True, False],
255 weights=[self.shortRangeNetworkFactor, 1 - self.shortRangeNetworkFactor],
256 k=1,
257 )
258 if connect[0] is True:
259 graph.add_edge(agent.unique_id, neighbor.unique_id, step=0)
260 longrange = [x for x in self.schedule.agents if x not in closerange and isinstance(x, SenderAgent)]
261 for neighbor in longrange:
262 if neighbor.unique_id != agent.unique_id:
263 connect = random.choices(
264 population=[True, False],
265 weights=[self.longRangeNetworkFactor, 1 - self.longRangeNetworkFactor],
266 k=1,
267 )
268 if connect[0] is True:
269 graph.add_edge(agent.unique_id, neighbor.unique_id, step=0)
271 def step(self) -> None:
272 """One simulation step."""
273 self.scaleSendInput.update(
274 **{x.unique_id: x.numLettersReceived for x in self.schedule.agents},
275 )
276 self.schedule.step()
277 self.datacollector.collect(self)
279 def step_no_data(self) -> None:
280 """One simulation step without datacollection."""
281 self.scaleSendInput.update(
282 **{x.unique_id: x.numLettersReceived for x in self.schedule.agents},
283 )
284 self.schedule.step()
286 def run(self, n:int) -> None:
287 """Run the model for n steps."""
288 if self.debug is True:
289 for _ in tqdm(range(n)):
290 self.step_no_data()
291 else:
292 for _ in range(n):
293 self.step_no_data()
294 self.datacollector.collect(self)