emod-api 3.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. emod_api/__init__.py +1 -0
  2. emod_api/campaign.py +170 -0
  3. emod_api/channelreports/__init__.py +0 -0
  4. emod_api/channelreports/channels.py +433 -0
  5. emod_api/channelreports/icj_to_csv.py +65 -0
  6. emod_api/channelreports/plot_icj_means.py +149 -0
  7. emod_api/channelreports/plot_prop_report.py +205 -0
  8. emod_api/channelreports/utils.py +326 -0
  9. emod_api/config/__init__.py +0 -0
  10. emod_api/config/default_from_schema.py +16 -0
  11. emod_api/config/default_from_schema_no_validation.py +177 -0
  12. emod_api/config/from_overrides.py +135 -0
  13. emod_api/demographics/__init__.py +0 -0
  14. emod_api/demographics/age_distribution.py +163 -0
  15. emod_api/demographics/base_input_file.py +28 -0
  16. emod_api/demographics/calculators.py +159 -0
  17. emod_api/demographics/demographic_exceptions.py +54 -0
  18. emod_api/demographics/demographics.py +249 -0
  19. emod_api/demographics/demographics_base.py +752 -0
  20. emod_api/demographics/demographics_overlay.py +41 -0
  21. emod_api/demographics/fertility_distribution.py +235 -0
  22. emod_api/demographics/implicit_functions.py +112 -0
  23. emod_api/demographics/mortality_distribution.py +227 -0
  24. emod_api/demographics/node.py +456 -0
  25. emod_api/demographics/overlay_node.py +16 -0
  26. emod_api/demographics/properties_and_attributes.py +737 -0
  27. emod_api/demographics/service/__init__.py +0 -0
  28. emod_api/demographics/service/grid_construction.py +143 -0
  29. emod_api/demographics/service/service.py +55 -0
  30. emod_api/demographics/susceptibility_distribution.py +170 -0
  31. emod_api/demographics/updateable.py +58 -0
  32. emod_api/legacy/__init__.py +0 -0
  33. emod_api/legacy/plotAllCharts.py +230 -0
  34. emod_api/migration/__init__.py +0 -0
  35. emod_api/migration/__main__.py +22 -0
  36. emod_api/migration/migration.py +782 -0
  37. emod_api/multidim_plotter.py +80 -0
  38. emod_api/schema_to_class.py +440 -0
  39. emod_api/serialization/__init__.py +0 -0
  40. emod_api/serialization/census_and_mod_pop.py +48 -0
  41. emod_api/serialization/dtk_file_support.py +61 -0
  42. emod_api/serialization/dtk_file_tools.py +1378 -0
  43. emod_api/serialization/dtk_file_utility.py +141 -0
  44. emod_api/serialization/serialized_population.py +205 -0
  45. emod_api/spatialreports/__init__.py +0 -0
  46. emod_api/spatialreports/__main__.py +67 -0
  47. emod_api/spatialreports/plot_spat_means.py +99 -0
  48. emod_api/spatialreports/spatial.py +210 -0
  49. emod_api/utils/__init__.py +26 -0
  50. emod_api/utils/distributions/__init__.py +0 -0
  51. emod_api/utils/distributions/base_distribution.py +38 -0
  52. emod_api/utils/distributions/bimodal_distribution.py +64 -0
  53. emod_api/utils/distributions/constant_distribution.py +58 -0
  54. emod_api/utils/distributions/demographic_distribution_flag.py +16 -0
  55. emod_api/utils/distributions/distribution_type.py +15 -0
  56. emod_api/utils/distributions/dual_constant_distribution.py +68 -0
  57. emod_api/utils/distributions/dual_exponential_distribution.py +75 -0
  58. emod_api/utils/distributions/exponential_distribution.py +63 -0
  59. emod_api/utils/distributions/gaussian_distribution.py +69 -0
  60. emod_api/utils/distributions/log_normal_distribution.py +61 -0
  61. emod_api/utils/distributions/poisson_distribution.py +59 -0
  62. emod_api/utils/distributions/uniform_distribution.py +70 -0
  63. emod_api/utils/distributions/weibull_distribution.py +69 -0
  64. emod_api/utils/str_enum.py +6 -0
  65. emod_api/weather/__init__.py +0 -0
  66. emod_api/weather/weather.py +428 -0
  67. emod_api-3.0.2.dist-info/METADATA +131 -0
  68. emod_api-3.0.2.dist-info/RECORD +71 -0
  69. emod_api-3.0.2.dist-info/WHEEL +5 -0
  70. emod_api-3.0.2.dist-info/licenses/LICENSE +21 -0
  71. emod_api-3.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,782 @@
1
+ from collections import defaultdict
2
+ from datetime import datetime
3
+ from functools import partial
4
+ import json
5
+ from numbers import Integral
6
+ from os import environ, SEEK_SET
7
+ from pathlib import Path
8
+ from platform import system
9
+ from warnings import warn
10
+
11
+ import numpy as np
12
+ import csv
13
+
14
+ from emod_api.demographics.demographics import Demographics
15
+
16
+ # for from_demog_and_param_gravity()
17
+ from geographiclib.geodesic import Geodesic
18
+
19
+
20
+ class Layer(dict):
21
+
22
+ """
23
+ The Layer object represents a mapping from source node (IDs) to destination node (IDs) for a particular
24
+ age, gender, age+gender combination, or all users if no age or gender dependence. Users will not generally
25
+ interact directly with Layer objects.
26
+ """
27
+
28
+ def __init__(self):
29
+
30
+ super().__init__()
31
+
32
+ return
33
+
34
+ @property
35
+ def DatavalueCount(self) -> int:
36
+ """Get (maximum) number of data values for any node in this layer
37
+
38
+ Returns:
39
+ Maximum number of data values for any node in this layer
40
+
41
+ """
42
+ count = max([len(entry) for entry in self.values()]) if len(self) else 0
43
+ return count
44
+
45
+ @property
46
+ def NodeCount(self) -> int:
47
+ """Get the number of (source) nodes with rates in this layer
48
+
49
+ Returns:
50
+ Number of (source) nodes with rates in this layer
51
+
52
+ """
53
+ return len(self)
54
+
55
+ # @property
56
+ # def Nodes(self) -> dict:
57
+ # return self._nodes
58
+
59
+ def __getitem__(self,
60
+ key: int) -> dict:
61
+ """Allows indexing directly into this object with source node id
62
+
63
+ Args:
64
+ key: source node id
65
+
66
+ Returns:
67
+ Dictionary of outbound rates for the given node id
68
+ """
69
+ if key not in self:
70
+ if isinstance(key, Integral):
71
+ super().__setitem__(key, defaultdict(float))
72
+ else:
73
+ raise RuntimeError(f"Migration node IDs must be integer values (key = {key}).")
74
+ return super().__getitem__(key)
75
+
76
+
77
+ _METADATA = "Metadata"
78
+ _AUTHOR = "Author"
79
+ _DATECREATED = "DateCreated"
80
+ _TOOLNAME = "Tool"
81
+ _IDREFERENCE = "IdReference"
82
+ _MIGRATIONTYPE = "MigrationType"
83
+ _NODECOUNT = "NodeCount"
84
+ _DATAVALUECOUNT = "DatavalueCount"
85
+ _GENDERDATATYPE = "GenderDataType"
86
+ _AGESYEARS = "AgesYears"
87
+ _INTERPOLATIONTYPE = "InterpolationType"
88
+ _NODEOFFSETS = "NodeOffsets"
89
+ _EMODAPI = "emod-api"
90
+
91
+
92
+ class Migration(object):
93
+
94
+ """Represents migration data in a mapping from source node (IDs) to destination node (IDs) with rates for each pairing.
95
+
96
+ Migration data may be age dependent, gender dependent, both, or the same for all ages and genders.
97
+ A migration file (along with JSON metadata) can be loaded from the static method Migration.from_file() and
98
+ inspected and/or modified.
99
+ Migration objects can be started from scratch with Migration(), and populated with appropriate source-dest rate data
100
+ and saved to a file with the to_file() method.
101
+ Given migration = Migration(), syntax is as follows:
102
+
103
+ age and gender agnostic: `migration[source_id][dest_id]`
104
+ age dependent: `migration[source_id:age]` # age should be >= 0, ages > last bucket value use last bucket value
105
+ gender dependent: `migration[source_id:gender]` # gender one of Migration.MALE or Migration.FEMALE
106
+ age and gender dependent: `migration[source_id:gender:age]` # gender one of Migration.MALE or Migration.FEMALE
107
+
108
+ EMOD/DTK format migration files (and associated metadata files) can be written with migration.to_file(<filename>).
109
+ EMOD/DTK format migration files (with associated metadata files) can be read with migration.from_file(<filename>).
110
+ """
111
+
112
+ SAME_FOR_BOTH_GENDERS = 0
113
+ ONE_FOR_EACH_GENDER = 1
114
+
115
+ LINEAR_INTERPOLATION = 0
116
+ PIECEWISE_CONSTANT = 1
117
+
118
+ LOCAL = 1
119
+ AIR = 2
120
+ REGIONAL = 3
121
+ SEA = 4
122
+ FAMILY = 5
123
+ INTERVENTION = 6
124
+
125
+ IDREF_LEGACY = "Legacy"
126
+ IDREF_GRUMP30ARCSEC = "Gridded world grump30arcsec"
127
+ IDREF_GRUMP2PT5ARCMIN = "Gridded world grump2.5arcmin"
128
+ IDREF_GRUMP1DEGREE = "Gridded world grump1degree"
129
+
130
+ MALE = 0
131
+ FEMALE = 1
132
+
133
+ MAX_AGE = 125
134
+
135
+ def __init__(self):
136
+
137
+ self._agesyears = []
138
+ try:
139
+ self._author = _author()
140
+ except Exception:
141
+ self._author = "Mystery Guest"
142
+ self._datecreated = datetime.now()
143
+ self._genderdatatype = self.SAME_FOR_BOTH_GENDERS
144
+ self._idreference = self.IDREF_LEGACY
145
+ self._interpolationtype = self.PIECEWISE_CONSTANT
146
+ self._migrationtype = self.LOCAL
147
+ self._tool = _EMODAPI
148
+
149
+ self._create_layers()
150
+
151
+ return
152
+
153
+ def _create_layers(self):
154
+
155
+ self._layers = []
156
+ for gender in range(0, self._genderdatatype + 1):
157
+ for age in range(0, len(self.AgesYears) if self.AgesYears else 1):
158
+ self._layers.append(Layer())
159
+
160
+ return
161
+
162
+ @property
163
+ def AgesYears(self) -> list:
164
+ """
165
+ List of ages - ages < first value use first bucket, ages > last value use last bucket.
166
+ """
167
+ return self._agesyears
168
+
169
+ @AgesYears.setter
170
+ def AgesYears(self, ages: list) -> None:
171
+ """
172
+ List of ages - ages < first value use first bucket, ages > last value use last bucket.
173
+ """
174
+ if sorted(ages) != self.AgesYears:
175
+ if self.NodeCount > 0:
176
+ warn("Changing age buckets clears existing migration information.", category=UserWarning)
177
+ self._agesyears = sorted(ages)
178
+ self._create_layers()
179
+ return
180
+
181
+ @property
182
+ def Author(self) -> str:
183
+ """str: Author value for metadata for this migration datafile"""
184
+ return self._author
185
+
186
+ @Author.setter
187
+ def Author(self, author: str) -> None:
188
+ self._author = author
189
+ return
190
+
191
+ @property
192
+ def DatavalueCount(self) -> int:
193
+ """int: Maximum data value count for any layer in this migration datafile"""
194
+ count = max([layer.DatavalueCount for layer in self._layers])
195
+ return count
196
+
197
+ @property
198
+ def DateCreated(self) -> datetime:
199
+ """datetime: date/time stamp of this datafile"""
200
+ return self._datecreated
201
+
202
+ @DateCreated.setter
203
+ def DateCreated(self, value) -> None:
204
+ if not isinstance(value, datetime):
205
+ raise RuntimeError(f"DateCreated must be a datetime value (got {type(value)}).")
206
+ self._datecreated = value
207
+ return
208
+
209
+ @property
210
+ def GenderDataType(self) -> int:
211
+ """int: gender data type for this datafile - SAME_FOR_BOTH_GENDERS or ONE_FOR_EACH_GENDER"""
212
+ return self._genderdatatype
213
+
214
+ @GenderDataType.setter
215
+ def GenderDataType(self, value: int) -> None:
216
+
217
+ # integer value
218
+ if value in Migration._GENDER_DATATYPE_ENUMS.keys():
219
+ value = int(value)
220
+ # string value
221
+ elif value in Migration._GENDER_DATATYPE_LOOKUP.keys():
222
+ value = Migration._GENDER_DATATYPE_LOOKUP[value]
223
+ else:
224
+ expected = [f"{key}/{value}" for key, value in Migration._GENDER_DATATYPE_LOOKUP.items()]
225
+ raise RuntimeError(f"Unknown gender data type, {value}, expected one of {expected}.")
226
+
227
+ if (self.NodeCount > 0) and (value != self._genderdatatype):
228
+ warn("Changing gender data type clears existing migration information.", category=UserWarning)
229
+
230
+ if value != self._genderdatatype:
231
+ self._genderdatatype = int(value)
232
+ self._create_layers()
233
+ return
234
+
235
+ @property
236
+ def IdReference(self) -> str:
237
+ """str: ID reference metadata value"""
238
+ return self._idreference
239
+
240
+ @IdReference.setter
241
+ def IdReference(self, value: str) -> None:
242
+ self._idreference = str(value)
243
+ return
244
+
245
+ @property
246
+ def InterpolationType(self) -> int:
247
+ """int: interpolation type for this migration data file - LINEAR_INTERPOLATION or PIECEWISE_CONSTANT"""
248
+ return self._interpolationtype
249
+
250
+ @InterpolationType.setter
251
+ def InterpolationType(self, value: int) -> None:
252
+
253
+ # integer value
254
+ if value in Migration._INTERPOLATION_TYPE_ENUMS.keys():
255
+ self._interpolationtype = int(value)
256
+ # string value
257
+ elif value in Migration._INTERPOLATION_TYPE_LOOKUP.keys():
258
+ self._interpolationtype = Migration._INTERPOLATION_TYPE_LOOKUP[value]
259
+ else:
260
+ expected = [f"{key}/{value}" for key, value in Migration._INTERPOLATION_TYPE_LOOKUP.items()]
261
+ raise RuntimeError(f"Unknown interpolation type, {value}, expected one of {expected}.")
262
+ return
263
+
264
+ @property
265
+ def MigrationType(self) -> int:
266
+ """int: migration type for this migration data file - LOCAL | AIR | REGIONAL | SEA | FAMILY | INTERVENTION"""
267
+ return self._migrationtype
268
+
269
+ @MigrationType.setter
270
+ def MigrationType(self, value: int) -> None:
271
+
272
+ # integer value
273
+ if value in Migration._MIGRATION_TYPE_ENUMS.keys():
274
+ self._migrationtype = int(value)
275
+ elif value in Migration._MIGRATION_TYPE_LOOKUP.keys():
276
+ self._migrationtype = Migration._MIGRATION_TYPE_LOOKUP[value]
277
+ else:
278
+ expected = [f"{key}/{value}" for key, value in Migration._MIGRATION_TYPE_LOOKUP.items()]
279
+ raise RuntimeError(f"Unknown migration type, {value}, expected one of {expected}.")
280
+ return
281
+
282
+ @property
283
+ def Nodes(self) -> list:
284
+ node_ids = set()
285
+ for layer in self._layers:
286
+ node_ids |= set(layer.keys())
287
+ node_ids = sorted(node_ids)
288
+ return node_ids
289
+
290
+ @property
291
+ def NodeCount(self) -> int:
292
+ """int: maximum number of source nodes in any layer of this migration data file"""
293
+ count = max([layer.NodeCount for layer in self._layers])
294
+ return count
295
+
296
+ def get_node_offsets(self, limit: int = 100) -> dict:
297
+ nodes = set()
298
+ for layer in self._layers:
299
+ nodes |= set(key for key in layer.keys())
300
+ count = min(self.DatavalueCount, limit)
301
+ # offsets = {}
302
+ # for index, node in enumerate(sorted(nodes)):
303
+ # offsets[node] = index * 12 * count
304
+ offsets = {node: 12 * index * count for index, node in enumerate(sorted(nodes))}
305
+ return offsets
306
+
307
+ @property
308
+ def NodeOffsets(self) -> dict:
309
+ """dict: mapping from source node id to offset to destination and rate data in binary data"""
310
+ return self.get_node_offsets()
311
+
312
+ @property
313
+ def Tool(self) -> str:
314
+ """str: tool metadata value"""
315
+ return self._tool
316
+
317
+ @Tool.setter
318
+ def Tool(self, value: str) -> None:
319
+ self._tool = str(value)
320
+ return
321
+
322
+ def __getitem__(self, key):
323
+ """allows indexing on this object to read/write rate data
324
+ Args:
325
+ key (slice): source node id:gender:age (gender and age depend on GenderDataType and AgesYears properties)
326
+ Returns:
327
+ dict for specified node/gender/age
328
+ """
329
+ if self.GenderDataType == Migration.SAME_FOR_BOTH_GENDERS:
330
+ if not self.AgesYears:
331
+ # Case 1 - no gender or age differentiation - key (integer) == node id
332
+ return self._layers[0][key]
333
+ else:
334
+ # Case 3 - age buckets, no gender differentiation - key (tuple or slice) == node id:age
335
+ if isinstance(key, tuple):
336
+ node_id, age = key
337
+ elif isinstance(key, slice):
338
+ node_id, age = key.start, key.stop
339
+ else:
340
+ raise RuntimeError(f"Invalid indexing for migration - {key}")
341
+ layer_index = self._index_for_gender_and_age(None, age)
342
+ return self._layers[layer_index][node_id]
343
+ else:
344
+ if not self.AgesYears:
345
+ # Case 2 - by gender, no age differentiation - key (tuple or slice) == node id:gender
346
+ if isinstance(key, tuple):
347
+ node_id, gender = key
348
+ elif isinstance(key, slice):
349
+ node_id, gender = key.start, key.stop
350
+ else:
351
+ raise RuntimeError(f"Invalid indexing for migration - {key}")
352
+ if gender not in [Migration.SAME_FOR_BOTH_GENDERS, Migration.ONE_FOR_EACH_GENDER]:
353
+ raise RuntimeError(f"Invalid gender ({gender}) for migration.")
354
+ layer_index = self._index_for_gender_and_age(gender, None)
355
+ return self._layers[layer_index][node_id]
356
+ else:
357
+ # Case 4 - by gender and age - key (slice) == node id:gender:age
358
+ if isinstance(key, tuple):
359
+ node_id, gender, age = key
360
+ elif isinstance(key, slice):
361
+ node_id, gender, age = key.start, key.stop, key.step
362
+ else:
363
+ raise RuntimeError(f"Invalid indexing for migration - {key}")
364
+ if gender not in [Migration.SAME_FOR_BOTH_GENDERS, Migration.ONE_FOR_EACH_GENDER]:
365
+ raise RuntimeError(f"Invalid gender ({gender}) for migration.")
366
+ layer_index = self._index_for_gender_and_age(gender, age)
367
+ return self._layers[layer_index][node_id]
368
+
369
+ # raise RuntimeError("Invalid state.")
370
+
371
+ def _index_for_gender_and_age(self, gender: int, age: float) -> int:
372
+ """
373
+ Use age to determine age bucket, 0 if no age differentiation.
374
+ Use gender data type to offset by # age buckets if gender data type is one for each gender and gender is female
375
+ Ages < first value use first bucket, ages > last value use last bucket.
376
+ """
377
+ age_offset = 0
378
+ for age_offset, edge in enumerate(self.AgesYears):
379
+ if edge >= age:
380
+ break
381
+ gender_span = len(self.AgesYears) if self.AgesYears else 1
382
+ gender_offset = gender * gender_span if self.GenderDataType == Migration.ONE_FOR_EACH_GENDER else 0
383
+ index = gender_offset + age_offset
384
+ return index
385
+
386
+ def __iter__(self):
387
+ return iter(self._layers)
388
+
389
+ _MIGRATION_TYPE_ENUMS = {
390
+ LOCAL: "LOCAL_MIGRATION",
391
+ AIR: "AIR_MIGRATION",
392
+ REGIONAL: "REGIONAL_MIGRATION",
393
+ SEA: "SEA_MIGRATION",
394
+ FAMILY: "FAMILY_MIGRATION",
395
+ INTERVENTION: "INTERVENTION_MIGRATION"
396
+ }
397
+
398
+ _GENDER_DATATYPE_ENUMS = {
399
+ SAME_FOR_BOTH_GENDERS: "SAME_FOR_BOTH_GENDERS",
400
+ ONE_FOR_EACH_GENDER: "ONE_FOR_EACH_GENDER"
401
+ }
402
+
403
+ _INTERPOLATION_TYPE_ENUMS = {
404
+ LINEAR_INTERPOLATION: "LINEAR_INTERPOLATION",
405
+ PIECEWISE_CONSTANT: "PIECEWISE_CONSTANT"
406
+ }
407
+
408
+ def to_file(self, binaryfile: Path, metafile: Path = None, value_limit: int = 100):
409
+ """Write current data to given file (and .json metadata file)
410
+
411
+ Args:
412
+ binaryfile (Path): path to output file (metadata will be written to same path with ".json" appended)
413
+ metafile (Path): override standard metadata file naming
414
+ value_limit (int): limit on number of destination values to write for each source node (default = 100)
415
+
416
+ Returns:
417
+ (Path): path to binary file
418
+ """
419
+ binaryfile = Path(binaryfile).absolute()
420
+ metafile = metafile if metafile else binaryfile.parent / (binaryfile.name + ".json")
421
+
422
+ actual_datavalue_count = min(self.DatavalueCount, value_limit) # limited to 100 destinations
423
+
424
+ node_ids = set()
425
+ for layer in self._layers:
426
+ node_ids |= set(layer.keys())
427
+ node_ids = sorted(node_ids)
428
+
429
+ offsets = self.get_node_offsets(actual_datavalue_count)
430
+ node_offsets_string = ''.join([f"{node:08x}{offsets[node]:08x}" for node in sorted(offsets.keys())])
431
+
432
+ metadata = {
433
+ _METADATA: {
434
+ _AUTHOR: self.Author,
435
+ _DATECREATED: f"{self.DateCreated:%a %b %d %Y %H:%M:%S}",
436
+ _TOOLNAME: self.Tool,
437
+ _IDREFERENCE: self.IdReference,
438
+ _MIGRATIONTYPE: self._MIGRATION_TYPE_ENUMS[self.MigrationType],
439
+ _NODECOUNT: self.NodeCount,
440
+ _DATAVALUECOUNT: actual_datavalue_count,
441
+ # could omit this if SAME_FOR_BOTH_GENDERS since it is the default
442
+ _GENDERDATATYPE: self._GENDER_DATATYPE_ENUMS[self.GenderDataType],
443
+ # _AGESYEARS: self.AgesYears, # see below
444
+ _INTERPOLATIONTYPE: self._INTERPOLATION_TYPE_ENUMS[self.InterpolationType]
445
+ },
446
+ _NODEOFFSETS: node_offsets_string
447
+ }
448
+ if self.AgesYears:
449
+ # older versions of Eradication do not handle empty AgesYears lists robustly
450
+ metadata[_METADATA][_AGESYEARS] = self.AgesYears
451
+
452
+ print(f"Writing metadata to '{metafile}'")
453
+ with metafile.open("w") as handle:
454
+ json.dump(metadata, handle, indent=4, separators=(",", ": "))
455
+
456
+ def key_func(k, d=None):
457
+ return d[k]
458
+
459
+ # layers are in age bucket order by gender, e.g. male 0-5, 5-10, 10+, female 0-5, 5-10, 10+
460
+ # see _index_for_gender_and_age()
461
+ print(f"Writing binary data to '{binaryfile}'")
462
+ with binaryfile.open("wb") as file:
463
+ for layer in self:
464
+ for node in node_ids:
465
+ destinations = np.zeros(actual_datavalue_count, dtype=np.uint32)
466
+ rates = np.zeros(actual_datavalue_count, dtype=np.float64)
467
+ if node in layer:
468
+
469
+ # Sort keys descending on rate and ascending on node ID.
470
+ # That way if we are truncating the list, we include the "most important" nodes.
471
+ keys = sorted(layer[node].keys()) # sorted ascending on node ID
472
+ keys = sorted(keys, key=partial(key_func, d=layer[node]), reverse=True) # descending on rate
473
+
474
+ if len(keys) > actual_datavalue_count:
475
+ keys = keys[0:actual_datavalue_count]
476
+ # save rates in ascending order so small rates are not lost when looking at the cumulative sum
477
+ keys = list(reversed(keys))
478
+ destinations[0:len(keys)] = keys
479
+ rates[0:len(keys)] = [layer[node][key] for key in keys]
480
+ else:
481
+ warn(f"No destination nodes found for node {node}", category=UserWarning)
482
+ destinations.tofile(file)
483
+ rates.tofile(file)
484
+
485
+ return binaryfile
486
+
487
+ _MIGRATION_TYPE_LOOKUP = {
488
+ "LOCAL_MIGRATION": LOCAL,
489
+ "AIR_MIGRATION": AIR,
490
+ "REGIONAL_MIGRATION": REGIONAL,
491
+ "SEA_MIGRATION": SEA,
492
+ "FAMILY_MIGRATION": FAMILY,
493
+ "INTERVENTION_MIGRATION": INTERVENTION
494
+ }
495
+
496
+ _GENDER_DATATYPE_LOOKUP = {
497
+ "SAME_FOR_BOTH_GENDERS": SAME_FOR_BOTH_GENDERS,
498
+ "ONE_FOR_EACH_GENDER": ONE_FOR_EACH_GENDER
499
+ }
500
+
501
+ _INTERPOLATION_TYPE_LOOKUP = {
502
+ "LINEAR_INTERPOLATION": LINEAR_INTERPOLATION,
503
+ "PIECEWISE_CONSTANT": PIECEWISE_CONSTANT
504
+ }
505
+
506
+
507
+ def from_file(binaryfile: Path,
508
+ metafile: Path = None) -> Migration:
509
+ """Reads migration data file from given binary (and associated JSON metadata file)
510
+
511
+ Args:
512
+ binaryfile (Path): path to binary file (metadata file is assumed to be at same location with ".json" suffix)
513
+ metafile (Path): use given metafile rather than inferring metafile name from the binary file name
514
+
515
+ Returns:
516
+ Migration object representing binary data in the given file.
517
+ """
518
+ binaryfile = Path(binaryfile).absolute()
519
+ metafile = metafile if metafile else binaryfile.parent / (binaryfile.name + ".json")
520
+
521
+ if not binaryfile.exists():
522
+ raise RuntimeError(f"Cannot find migration binary file '{binaryfile}'")
523
+ if not metafile.exists():
524
+ raise RuntimeError(f"Cannot find migration metadata file '{metafile}'.")
525
+ with metafile.open("r") as file:
526
+ jason = json.load(file)
527
+
528
+ # these are the minimum required entries to load a migration file
529
+ assert _METADATA in jason, f"Metadata file '{metafile}' does not have a 'Metadata' entry."
530
+ metadata = jason[_METADATA]
531
+ assert _NODECOUNT in metadata, f"Metadata file '{metafile}' does not have a 'NodeCount' entry."
532
+ assert _DATAVALUECOUNT in metadata, f"Metadata file '{metafile}' does not have a 'DatavalueCount' entry."
533
+ assert _NODEOFFSETS in jason, f"Metadata file '{metafile}' does not have a 'NodeOffsets' entry."
534
+
535
+ migration = Migration()
536
+ migration.Author = _value_with_default(metadata, _AUTHOR, _author())
537
+ migration.DateCreated = _try_parse_date(metadata[_DATECREATED]) if _DATECREATED in metadata else datetime.now()
538
+ migration.Tool = _value_with_default(metadata, _TOOLNAME, _EMODAPI)
539
+ migration.IdReference = _value_with_default(metadata, _IDREFERENCE, Migration.IDREF_LEGACY)
540
+ migration.MigrationType = Migration._MIGRATION_TYPE_LOOKUP[_value_with_default(metadata,
541
+ _MIGRATIONTYPE,
542
+ "LOCAL_MIGRATION")]
543
+ migration.GenderDataType = Migration._GENDER_DATATYPE_LOOKUP[_value_with_default(metadata,
544
+ _GENDERDATATYPE,
545
+ "SAME_FOR_BOTH_GENDERS")]
546
+ migration.AgesYears = _value_with_default(metadata, _AGESYEARS, [])
547
+ migration.InterpolationType = Migration._INTERPOLATION_TYPE_LOOKUP[_value_with_default(metadata,
548
+ _INTERPOLATIONTYPE,
549
+ "PIECEWISE_CONSTANT")]
550
+
551
+ node_count = metadata[_NODECOUNT]
552
+ node_offsets = jason[_NODEOFFSETS]
553
+ if len(node_offsets) != 16 * node_count:
554
+ raise RuntimeError(f"Length of node offsets string {len(node_offsets)} != 16 * node count {node_count}.")
555
+ offsets = _parse_node_offsets(node_offsets, node_count)
556
+ datavalue_count = metadata[_DATAVALUECOUNT]
557
+ with binaryfile.open("rb") as file:
558
+ for gender in range(1 if migration.GenderDataType == Migration.SAME_FOR_BOTH_GENDERS else 2):
559
+ for age in migration.AgesYears if migration.AgesYears else [0]:
560
+ layer = migration._layers[migration._index_for_gender_and_age(gender, age)]
561
+ for node, offset in offsets.items():
562
+ file.seek(offset, SEEK_SET)
563
+ destinations = np.fromfile(file, dtype=np.uint32, count=datavalue_count)
564
+ rates = np.fromfile(file, dtype=np.float64, count=datavalue_count)
565
+ for destination, rate in zip(destinations, rates):
566
+ if rate > 0:
567
+ layer[node][destination] = rate
568
+
569
+ return migration
570
+
571
+
572
+ def examine_file(filename):
573
+
574
+ def name_for_gender_datatype(e: int) -> str:
575
+ return Migration._GENDER_DATATYPE_ENUMS[e] if e in Migration._GENDER_DATATYPE_ENUMS else "unknown"
576
+
577
+ def name_for_interpolation(e: int) -> str:
578
+ return Migration._INTERPOLATION_TYPE_ENUMS[e] if e in Migration._INTERPOLATION_TYPE_ENUMS else "unknown"
579
+
580
+ def name_for_migration_type(e: int) -> str:
581
+ return Migration._MIGRATION_TYPE_ENUMS[e] if e in Migration._MIGRATION_TYPE_ENUMS else "unknown"
582
+
583
+ migration = from_file(filename)
584
+ print(f"Author: {migration.Author}")
585
+ print(f"DatavalueCount: {migration.DatavalueCount}")
586
+ print(f"DateCreated: {migration.DateCreated:%a %B %d %Y %H:%M}")
587
+ print(f"GenderDataType: {migration.GenderDataType} ({name_for_gender_datatype(migration.GenderDataType)})")
588
+ print(f"IdReference: {migration.IdReference}")
589
+ print(f"InterpolationType: {migration.InterpolationType} ({name_for_interpolation(migration.InterpolationType)})")
590
+ print(f"MigrationType: {migration.MigrationType} ({name_for_migration_type(migration.MigrationType)})")
591
+ print(f"NodeCount: {migration.NodeCount}")
592
+ print(f"NodeOffsets: {migration.NodeOffsets}")
593
+ print(f"Tool: {migration.Tool}")
594
+ print(f"Nodes: {migration.Nodes}")
595
+
596
+ return
597
+
598
+
599
+ def _author() -> str:
600
+ username = "Unknown"
601
+ if system() == "Windows":
602
+ username = environ["USERNAME"]
603
+ elif "USER" in environ:
604
+ username = environ["USER"]
605
+ return username
606
+
607
+
608
+ def _parse_node_offsets(string: str, count: int) -> dict:
609
+
610
+ assert len(string) == 16 * count, f"Length of node offsets string {len(string)} != 16 * node count {count}."
611
+
612
+ offsets = {}
613
+ for index in range(count):
614
+ base = 16 * index
615
+ offset = base + 8
616
+ offsets[int(string[base:base + 8], 16)] = int(string[offset:offset + 8], 16)
617
+
618
+ return offsets
619
+
620
+
621
+ def _try_parse_date(string: str) -> datetime:
622
+
623
+ patterns = [
624
+ "%a %b %d %Y %H:%M:%S",
625
+ "%a %b %d %H:%M:%S %Y",
626
+ "%m/%d/%Y",
627
+ "%Y-%m-%d %H:%M:%S.%f"
628
+ ]
629
+
630
+ for pattern in patterns:
631
+ try:
632
+ timestamp = datetime.strptime(string, pattern)
633
+ return timestamp
634
+ except ValueError:
635
+ pass
636
+
637
+ timestamp = datetime.now()
638
+ warn(f"Could not parse date stamp '{string}', using datetime.now() ({timestamp})")
639
+
640
+ return timestamp
641
+
642
+
643
+ def _value_with_default(dictionary: dict, key: str, default: object) -> object:
644
+ return dictionary[key] if key in dictionary else default
645
+
646
+
647
+ """
648
+ utility functions emodpy-utils?
649
+ """
650
+
651
+
652
+ def from_demog_and_param_gravity(demographics_file_path, gravity_params, id_ref, migration_type=Migration.LOCAL):
653
+ demog = Demographics.from_file(demographics_file_path)
654
+ return _from_demog_and_param_gravity(demog, gravity_params, id_ref, migration_type)
655
+
656
+
657
+ def _from_demog_and_param_gravity(demographics, gravity_params, id_ref, migration_type=Migration.LOCAL):
658
+ """
659
+ Create migration files from a gravity model and an input demographics file.
660
+ """
661
+
662
+ def _compute_migr_prob(grav_params, home_pop, dest_pop, dist):
663
+ """
664
+ Utility function for computing migration probabilities for gravity model.
665
+ """
666
+
667
+ # If home/dest node has 0 pop, assume this node is the regional work node-- no local migration allowed
668
+ if home_pop == 0 or dest_pop == 0:
669
+ return 0.
670
+ else:
671
+ num_trips = grav_params[0] * home_pop ** grav_params[1] * dest_pop ** grav_params[2] * dist ** grav_params[3]
672
+ prob_trip = np.min([1., num_trips / home_pop])
673
+ return prob_trip
674
+
675
+ def _compute_migr_dict(node_list, grav_params, **kwargs):
676
+ """
677
+ Utility function for computing migration value map.
678
+ """
679
+
680
+ excluded_nodes = set(kwargs["exclude_nodes"]) if "exclude_nodes" in kwargs else set()
681
+
682
+ mig = Migration()
683
+ geodesic = Geodesic.WGS84
684
+
685
+ for source_node in node_list:
686
+
687
+ source_id = source_node["NodeID"]
688
+ src_lat = source_node["NodeAttributes"]["Latitude"]
689
+ src_long = source_node["NodeAttributes"]["Longitude"]
690
+ src_pop = source_node["NodeAttributes"]["InitialPopulation"]
691
+
692
+ if source_id in excluded_nodes:
693
+ continue
694
+
695
+ for destination_node in node_list:
696
+
697
+ if destination_node == source_node:
698
+ continue
699
+
700
+ dest_id = destination_node["NodeID"]
701
+
702
+ if dest_id in excluded_nodes:
703
+ continue
704
+
705
+ dst_lat = destination_node["NodeAttributes"]["Latitude"]
706
+ dst_long = destination_node["NodeAttributes"]["Longitude"]
707
+ dst_pop = destination_node["NodeAttributes"]["InitialPopulation"]
708
+
709
+ distance = geodesic.Inverse(src_lat, src_long, dst_lat, dst_long, Geodesic.DISTANCE)['s12'] / 1000 # km
710
+ probability = _compute_migr_prob(grav_params, src_pop, dst_pop, distance)
711
+
712
+ mig[source_id][dest_id] = probability
713
+
714
+ return mig
715
+
716
+ # load
717
+ nodes = [node.to_dict() for node in demographics.nodes]
718
+ migration = _compute_migr_dict(nodes, gravity_params)
719
+ migration.IdReference = id_ref
720
+ migration.MigrationType = migration_type
721
+
722
+ return migration
723
+
724
+
725
+ # by gender, by age
726
+ _mapping_fns = {
727
+ (False, False): lambda m, i, g, a: m[i],
728
+ (False, True): lambda m, i, g, a: m[i:a],
729
+ (True, False): lambda m, i, g, a: m[i:g],
730
+ (True, True): lambda m, i, g, a: m[i:g:a]
731
+ }
732
+
733
+ # by gender, by age
734
+ _display_fns = {
735
+ (False, False): lambda i, g, a, d, r: f"{i},{d},{r}", # id only
736
+ (False, True): lambda i, g, a, d, r: f"{i},{a},{d},{r}", # id:age
737
+ (True, False): lambda i, g, a, d, r: f"{i},{g},{d},{r}", # id:gender
738
+ (True, True): lambda i, g, a, d, r: f"{i},{g},{a},{d},{r}" # id:gender:age
739
+ }
740
+
741
+
742
+ def to_csv(filename: Path):
743
+
744
+ migration = from_file(filename)
745
+
746
+ mapping = _mapping_fns[(migration.GenderDataType == Migration.ONE_FOR_EACH_GENDER, bool(migration.AgesYears))]
747
+ display = _display_fns[(migration.GenderDataType == Migration.ONE_FOR_EACH_GENDER, bool(migration.AgesYears))]
748
+
749
+ print(display("node", "gender", "age", "destination", "rate"))
750
+ for gender in range(1 if migration.GenderDataType == Migration.SAME_FOR_BOTH_GENDERS else 2):
751
+ for age in migration.AgesYears if migration.AgesYears else [0]:
752
+ for node in migration.Nodes:
753
+ for destination, rate in mapping(migration, node, gender, age).items():
754
+ print(display(node, gender, age, destination, rate))
755
+
756
+
757
+ def from_csv(filename: Path,
758
+ id_ref,
759
+ mig_type=None) -> Migration:
760
+ """Create migration from csv file. The file should have columns 'source' for the source node, 'destination' for the destination node, and 'rate' for the migration rate.
761
+
762
+ Args:
763
+ filename: csv file
764
+
765
+ Returns:
766
+ Migration object
767
+ """
768
+ migration = Migration()
769
+ migration.IdReference = id_ref
770
+ if not mig_type:
771
+ mig_type = Migration.LOCAL
772
+ else:
773
+ migration._migrationtype = mig_type
774
+ with filename.open("r") as csvfile:
775
+ reader = csv.DictReader(csvfile)
776
+ csv_data_read = False
777
+ for row in reader:
778
+ csv_data_read = True
779
+ migration[int(row['source'])][int(row['destination'])] = float(row['rate'])
780
+ assert csv_data_read, "Csv file %s does not contain migration data." % filename
781
+
782
+ return migration