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,41 @@
1
+ import json
2
+
3
+ from emod_api.demographics.demographics_base import DemographicsBase
4
+ from emod_api.demographics.overlay_node import OverlayNode
5
+
6
+
7
+ class DemographicsOverlay(DemographicsBase):
8
+ """
9
+ This class inherits from :py:obj:`emod_api:emod_api.demographics.DemographicsBase` so all functions that can be used
10
+ to create demographics can also be used to create an overlay file. The intended use is for a user to pass a
11
+ self-built default OverlayNode object in to represent the Defaults section in the demographics overlay.
12
+ """
13
+
14
+ def __init__(self,
15
+ default_node: OverlayNode,
16
+ nodes: list[OverlayNode] = None,
17
+ idref: str = None):
18
+ """
19
+ An object representation of an EMOD demographics overlay input (file). The contents are interpreted by EMOD
20
+ at runtime as overrides to the canonical/primary demographics input file.
21
+
22
+ Args:
23
+ default_node: (OverlayNode) Contains default settings for nodes in the overlay.
24
+ nodes (List[OverlayNode]): Overlay is applied to these nodes. Default is no nodes.
25
+ idref (str): a name used to indicate files (demographics, climate, and migration) are used together
26
+ """
27
+ nodes = [] if nodes is None else nodes
28
+ super().__init__(nodes=nodes, idref=idref, default_node=default_node)
29
+
30
+ def to_file(self, file_name: str = "demographics_overlay.json") -> None:
31
+ """
32
+ Writes the DemographicsOverlay to an EMOD-compatible json file.
33
+
34
+ Args:
35
+ file_name (str): The filepath to write to.
36
+
37
+ Returns:
38
+ Nothing
39
+ """
40
+ with open(file_name, "w") as demo_override_f:
41
+ json.dump(self.to_dict(), demo_override_f)
@@ -0,0 +1,235 @@
1
+ import emod_api.demographics.demographic_exceptions as demog_ex
2
+
3
+ from emod_api.demographics.updateable import Updateable
4
+ from emod_api.utils import check_dimensionality
5
+
6
+
7
+ class FertilityDistribution(Updateable):
8
+ def __init__(self,
9
+ ages_years: list[float],
10
+ calendar_years: list[float],
11
+ pregnancy_rate_matrix: list[list[float]]):
12
+ """
13
+ A pregancies/births distribution in units of "annual birth rate per 1000 women". For alternative representations
14
+ of fertlity/birth in EMOD, see config parameter Birth_Rate_Dependence for more details.
15
+
16
+ The FertilityDistribution is used to determine the rate of pregnancies that a "possible mother" will have based
17
+ on the individual's age and the calendar year. A woman is a possible mother if her age is between (14.0, 45.0)
18
+ non-inclusive and is not already pregnant. Once a woman becomes pregnant, she will be pregnant for 40 weeks and
19
+ then give birth.
20
+
21
+ EMOD uses double linear interpolation (bilinear) of a 'possible mothers' age and the current calendar year to
22
+ determine her probability of becoming pregnant. At every time step, 'possible mothers' are identified and are
23
+ then probabilistically checked to determine if they become pregnant.
24
+ - See https://www.wikihow.com/Do-a-Double-Linear-Interpolation
25
+
26
+ Fertility at any age or any year that preceeds or exceeds the supplied data will be equal to the value at
27
+ the nearest age and/or timepoint of supplied data.
28
+
29
+ In order to model the transfer of immunity or infection from mother to child, one must model pregnancies.
30
+
31
+ NOTE: In the limit of low birth rate, the probability of becoming pregnant is equivalent to the birth rate.
32
+ However, at higher birth rates, some fraction of possible mothers will already be pregnant.
33
+ Roughly speaking, if we want women to give birth every other year, and they gestate for one year,
34
+ then the expected time between pregnancy has to be one year, not two.
35
+ Hence, the maximum possible birth rate is 1 child per woman per gestation period.
36
+
37
+ To determine if a woman becomes pregnant, the following logic is used:
38
+
39
+ birthrate = FertilityDistribution.draw_value( age, calendar_year )
40
+ birthrate = birthrate / (1.0 - birthrate * DAYSPERWEEK(7) * WEEKS_FOR_GESTATION(40))
41
+ birth_probability = birthrate * dt * x_Birth
42
+ is_pregnant = uniform_random_number < birth_probability
43
+
44
+ Args:
45
+ ages_years (list[float]): A list of ages (in years) that fertility data will be provided for. Must be a
46
+ list of monotonically increasing floats. Regardless of the provided ages and data, women in EMOD can
47
+ only be possible mothers if their age is between (14.0, 45.0) non-inclusive.
48
+
49
+ calendar_years (list[float]): A list of times (in calendar years) that fertility data will be
50
+ provided for. Must be a list of monotonically increasing floats within range 1900 <= year <= 2200 .
51
+
52
+ pregnancy_rate_matrix (list[list[float]]): A 2-d grid of fertility rates in units of
53
+ "annual birth rate per 1000 women". The first data dimension (index) is by age, the second data
54
+ dimension is by calendar year. For M ages (in years) and N calendars years, the dimensionality of this
55
+ matrix must be MxN .
56
+
57
+ Example:
58
+ ages_years: [15.0, 24.999, 25.0, 34.999, 35.0, 44.999, 45.0, 125.0] # M ages
59
+ calendar_years: [2010.0, 2014.999, 2015.0, 2019.999, 2020.0, 2024.999] # N times
60
+ pregnancy_rate_matrix: dimensionality MxN, units: fertility/year/1000 women
61
+ [[103.3, 103.3, 77.5, 77.5, 65.5, 65.5], # fertility rates at age 15.0, the six timepoints above
62
+ [103.3, 103.3, 77.5, 77.5, 65.5, 65.5], # fertility rates at age 24.999
63
+ [265.0, 265.0, 278.7, 278.7, 275.4, 275.4], # fertility rates at age 25.0
64
+ [265.0, 265.0, 278.7, 278.7, 275.4, 275.4], # fertility rates at age 34.999
65
+ [152.4, 152.4, 129.2, 129.2, 115.9, 115.9], # fertility rates at age 35.0
66
+ [152.4, 152.4, 129.2, 129.2, 115.9, 115.9], # fertility rates at age 44.999
67
+ [19.9, 19.9, 14.6, 14.6, 12.1, 12.1], # fertility rates at age 45.0
68
+ [19.9, 19.9, 14.6, 14.6, 12.1, 12.1]] # fertility rates at age 125.0
69
+
70
+ A 30 year-old woman who is not pregnant at time/year 2022.5 bilinearly interpolated (shown in steps):
71
+ 275.4 + (2022.5-2020.0) * ((275.4-275.4) / (2024.999-2020.0)) = 275.4 (age 25.0 fertility at 2022.5)
72
+ 275.4 + (2022.5-2020.0) * ((275.4-275.4) / (2024.999-2020.0)) = 275.4 (age 34.99 fertility at 2022.5)
73
+ 275.4 + (30-25.0) * ((275.4-275.4) / (34.999-25)) = 275.4 (age 30 fertility at 2022.5)
74
+ scale result to fertility/woman/day: 275.4 / (365 * 1000) = 0.0007545 (birth probability)
75
+ """
76
+ super().__init__()
77
+ self.ages_years = ages_years
78
+ self.calendar_years = calendar_years
79
+ self.pregnancy_rate_matrix = pregnancy_rate_matrix
80
+
81
+ # This will convert the object to a fertility dictionary and then validate it reporting object-relevant messages
82
+ self._validate(distribution_dict=self.to_dict(validate=False), source_is_dict=False)
83
+
84
+ @property
85
+ def _population_groups(self):
86
+ return [self.ages_years, self.calendar_years]
87
+
88
+ @classmethod
89
+ def _rate_scale_factor(cls):
90
+ return 1 / 365.0 / 1000 # convert from per-year, per 1000 women to per/woman/day
91
+
92
+ @classmethod
93
+ def _rate_scale_units(cls):
94
+ return "annual birth rate per 1000 women"
95
+
96
+ @classmethod
97
+ def _axis_names(cls):
98
+ return ['age', 'year']
99
+
100
+ @classmethod
101
+ def _axis_scale_factors(cls):
102
+ return [365.0, 1]
103
+
104
+ def to_dict(self, validate: bool = True) -> dict:
105
+ distribution_dict = {
106
+ 'AxisNames': self._axis_names(),
107
+ 'AxisScaleFactors': self._axis_scale_factors(),
108
+ 'PopulationGroups': self._population_groups,
109
+ 'ResultScaleFactor': self._rate_scale_factor(),
110
+ 'ResultUnits': self._rate_scale_units(),
111
+ 'ResultValues': self.pregnancy_rate_matrix
112
+ }
113
+ if validate:
114
+ self._validate(distribution_dict=distribution_dict, source_is_dict=False)
115
+ return distribution_dict
116
+
117
+ @classmethod
118
+ def from_dict(cls, distribution_dict: dict):
119
+ cls._validate(distribution_dict=distribution_dict, source_is_dict=True)
120
+ return cls(ages_years=distribution_dict['PopulationGroups'][0],
121
+ calendar_years=distribution_dict['PopulationGroups'][1],
122
+ pregnancy_rate_matrix=distribution_dict['ResultValues'])
123
+
124
+ # True means message relevant to verifying a fertility dictionary, False means messages relevant to verifying an obj
125
+ _validation_messages = {
126
+ 'fixed_value_check': {
127
+ True: "key: {0} value: {1} does not match expected value: {2}",
128
+ False: None # These are all properties of the obj and cannot be made invalid
129
+ },
130
+ 'population_group_length_check': {
131
+ True: "PopulationGroups expected to be a 2-d array of floats. The first dimension length must be two, but "
132
+ "is length {0}",
133
+ False: None # This is a property of the obj and cannot be made invalid
134
+ },
135
+ 'data_dimensionality_check': {
136
+ True: "ResultValues is expected to be a 2-d matrix of data but it is not.",
137
+ False: "pregnancy_rate_matrix has an improper dimensionality. It must be a 2-d matrix."
138
+ },
139
+ 'data_dimensionality_check_dim0': {
140
+ True: "ResultValues first dimension length {0} does not match the PopulationGroups[0] age bin count {1}",
141
+ False: "pregnancy_rate_matrix first dimension length: {0} does not match the ages_years length: {1}"
142
+ },
143
+ 'data_dimensionality_check_dim1': {
144
+ True: "ResultValues second dimension length {0} does not match the PopulationGroups[1] time bin count: {1}",
145
+ False: "pregnancy_rate_matrix second dimension length: {0} does not match the calendar_years length: {1}"
146
+ },
147
+ 'age_range_check': {
148
+ True: "PopulationGroups[0] age values must be: 0 <= age <= 200 in years. Out-of-range index:values : {0}",
149
+ False: "All ages_years values must be: 0 <= age <= 200 in years. Out-of-range index:values : {0}"
150
+ },
151
+ 'time_range_check': {
152
+ True: "PopulationGroups[1] time values must be: 1900 <= time <= 2200 calendar year",
153
+ False: "All calendar_years values must be: 1900 <= time <= 2200 calendar year"
154
+ },
155
+ 'age_monotonicity_check': {
156
+ True: "PopulationGroups[0] ages in years must monotonically increase but do not, index: {0} value: {1}",
157
+ False: "ages_years values must monotonically increase but do not, index: {0} value: {1}"
158
+ },
159
+ 'time_monotonicity_check': {
160
+ True: "PopulationGroups[1] times in calendar years must monotonically increase but do not, index: {0} value: {1}",
161
+ False: "calendar_years values must monotonically increase but do not, index: {0} value: {1}"
162
+ }
163
+ }
164
+
165
+ @classmethod
166
+ def _validate(cls, distribution_dict: dict, source_is_dict: bool):
167
+ """
168
+ Validate a FertilityDistribution in dict form
169
+
170
+ Args:
171
+ distribution_dict: (dict) the fertility dict to validate
172
+ source_is_dict: (bool) If true, report dict-relevant error messages. If false, report obj-relevant messages.
173
+
174
+ Returns:
175
+ Nothing
176
+ """
177
+ if source_is_dict is True:
178
+ expected_values = {
179
+ 'AxisNames': cls._axis_names(),
180
+ 'AxisScaleFactors': cls._axis_scale_factors(),
181
+ 'ResultScaleFactor': cls._rate_scale_factor(),
182
+ 'ResultUnits': cls._rate_scale_units()
183
+ }
184
+ for key, expected_value in expected_values.items():
185
+ value = distribution_dict[key]
186
+ if value != expected_value:
187
+ message = cls._validation_messages['fixed_value_check'][source_is_dict].format(key, value, expected_value)
188
+ raise demog_ex.InvalidFixedValueException(message)
189
+
190
+ # ensure the data table is MxN for the population groups == [M, N]
191
+ population_groups = distribution_dict['PopulationGroups']
192
+ data_table = distribution_dict['ResultValues']
193
+ if source_is_dict is True:
194
+ if len(population_groups) != 2:
195
+ message = cls._validation_messages['population_group_length_check'][source_is_dict].format(len(population_groups))
196
+ raise demog_ex.InvalidPopulationGroupLengthException(message)
197
+
198
+ # ensure the data table has the correct dimensionality. It must be 2-d.
199
+ is_2d = check_dimensionality(data=data_table, dimensionality=2)
200
+ if not is_2d:
201
+ message = cls._validation_messages['data_dimensionality_check'][source_is_dict]
202
+ raise demog_ex.InvalidDataDimensionality(message)
203
+
204
+ # continue checking dimension lengths
205
+ ages = population_groups[0]
206
+ times = population_groups[1]
207
+ n_ages = len(ages)
208
+ n_times = len(times)
209
+ if len(data_table) != n_ages:
210
+ message = cls._validation_messages['data_dimensionality_check_dim0'][source_is_dict].format(len(data_table), n_ages)
211
+ raise demog_ex.InvalidDataDimensionDim0Exception(message)
212
+ for i in range(len(data_table)):
213
+ if len(data_table[i]) != n_times:
214
+ message = cls._validation_messages['data_dimensionality_check_dim1'][source_is_dict].format(len(data_table[i]), n_times)
215
+ raise demog_ex.InvalidDataDimensionDim1Exception(message)
216
+
217
+ # ensure the age and time lists are ascending and in reasonable ranges
218
+ out_of_range = [f"{index}:{age}" for index, age in enumerate(ages) if (age < 0) or (age > 200)]
219
+ if len(out_of_range) > 0:
220
+ oor_str = ', '.join(out_of_range)
221
+ message = cls._validation_messages['age_range_check'][source_is_dict].format(oor_str)
222
+ raise demog_ex.AgeOutOfRangeException(message)
223
+
224
+ if any([(time < 1900) or (time > 2200) for time in times]):
225
+ message = cls._validation_messages['time_range_check'][source_is_dict]
226
+ raise demog_ex.TimeOutOfRangeException(message)
227
+
228
+ for i in range(1, len(ages)):
229
+ if ages[i] - ages[i - 1] <= 0:
230
+ message = cls._validation_messages['age_monotonicity_check'][source_is_dict].format(i, ages[i])
231
+ raise demog_ex.NonMonotonicAgeException(message)
232
+ for i in range(1, len(times)):
233
+ if times[i] - times[i - 1] <= 0:
234
+ message = cls._validation_messages['time_monotonicity_check'][source_is_dict].format(i, times[i])
235
+ raise demog_ex.NonMonotonicTimeException(message)
@@ -0,0 +1,112 @@
1
+ # Migration
2
+
3
+ def _set_migration_model_fixed_rate(config):
4
+ config.parameters.Migration_Model = "FIXED_RATE_MIGRATION"
5
+ return config
6
+
7
+
8
+ def _set_enable_migration_model_heterogeneity(config):
9
+ config.parameters.Enable_Migration_Heterogeneity = 1
10
+ return config
11
+
12
+
13
+ def _set_migration_pattern_srt(config):
14
+ config.parameters.Migration_Pattern = "SINGLE_ROUND_TRIPS"
15
+ return config
16
+
17
+
18
+ def _set_migration_pattern_rwd(config):
19
+ config.parameters.Migration_Pattern = "RANDOM_WALK_DIFFUSION"
20
+ return config
21
+
22
+
23
+ def _set_regional_migration_filenames(config, file_name):
24
+ config.parameters.Regional_Migration_Filename = file_name
25
+ return config
26
+
27
+
28
+ def _set_local_migration_filename(config, file_name):
29
+ config.parameters.Local_Migration_Filename = file_name
30
+ return config
31
+
32
+
33
+ def _set_demographic_filenames(config, filenames):
34
+ config.parameters.Demographics_Filenames = filenames
35
+ return config
36
+
37
+
38
+ def _set_local_migration_roundtrip_probability(config, probability_of_return):
39
+ config.parameters.Local_Migration_Roundtrip_Probability = probability_of_return
40
+ return config
41
+
42
+
43
+ def _set_regional_migration_roundtrip_probability(config, probability_of_return):
44
+ config.parameters.Regional_Migration_Roundtrip_Probability = probability_of_return
45
+ return config
46
+
47
+
48
+ # Susceptibility
49
+
50
+ def _set_suscept_complex(config):
51
+ config.parameters.Susceptibility_Initialization_Distribution_Type = "DISTRIBUTION_COMPLEX"
52
+ return config
53
+
54
+
55
+ def _set_suscept_simple(config):
56
+ config.parameters.Susceptibility_Initialization_Distribution_Type = "DISTRIBUTION_SIMPLE"
57
+ return config
58
+
59
+
60
+ # Age Structure
61
+
62
+ def _set_age_simple(config):
63
+ config.parameters.Age_Initialization_Distribution_Type = "DISTRIBUTION_SIMPLE"
64
+ return config
65
+
66
+
67
+ def _set_age_complex(config):
68
+ config.parameters.Age_Initialization_Distribution_Type = "DISTRIBUTION_COMPLEX"
69
+ return config
70
+
71
+
72
+ # Initial Prevalence
73
+
74
+ def _set_init_prev(config):
75
+ config.parameters.Enable_Initial_Prevalence = 1
76
+ return config
77
+
78
+
79
+ # Mortality
80
+
81
+ def _set_enable_natural_mortality(config):
82
+ config.parameters.Enable_Natural_Mortality = 1
83
+ return config
84
+
85
+
86
+ def _set_mortality_age_gender(config):
87
+ config.parameters.Death_Rate_Dependence = "NONDISEASE_MORTALITY_BY_AGE_AND_GENDER"
88
+ return config
89
+
90
+
91
+ def _set_mortality_age_gender_year(config):
92
+ config.parameters.Death_Rate_Dependence = "NONDISEASE_MORTALITY_BY_YEAR_AND_AGE_FOR_EACH_GENDER"
93
+ return config
94
+
95
+
96
+ # Fertility
97
+
98
+ def _set_fertility_age_year(config):
99
+ config.parameters.Birth_Rate_Dependence = "INDIVIDUAL_PREGNANCIES_BY_AGE_AND_YEAR"
100
+ return config
101
+
102
+
103
+ def _set_population_dependent_birth_rate(config):
104
+ config.parameters.Birth_Rate_Dependence = "POPULATION_DEP_RATE"
105
+ return config
106
+
107
+
108
+ # Risk
109
+
110
+ def _set_enable_demog_risk(config):
111
+ config.parameters.Enable_Demographics_Risk = 1
112
+ return config
@@ -0,0 +1,227 @@
1
+ from typing import Union
2
+
3
+ import emod_api.demographics.demographic_exceptions as demog_ex
4
+ from emod_api.demographics.updateable import Updateable
5
+ from emod_api.utils import check_dimensionality
6
+
7
+
8
+ class MortalityDistribution(Updateable):
9
+ def __init__(self,
10
+ ages_years: list[float],
11
+ mortality_rate_matrix: Union[list[list[float]], list[float]],
12
+ calendar_years: list[float] = None):
13
+ """
14
+ A natural mortality distribution for one gender in units of "annual death rate for an individual". If the
15
+ distribution is time-dependent, pass in a list of times (calendar_years).
16
+
17
+ The MortalityDistribution provides a rate (probability) at which each agent will die naturally on any given
18
+ model day given their current age. EMOD uses double linear interpolation (bilinear) of an agent's age and the
19
+ current calendar year to determine the exact probability of their death.
20
+ - See https://www.wikihow.com/Do-a-Double-Linear-Interpolation
21
+
22
+ Mortality at any age or any year that preceeds or exceeds the supplied data will be equal to the value at
23
+ the nearest age and/or timepoint of supplied data.
24
+
25
+ Args:
26
+ ages_years: (list[float]) A list of ages (in years) that mortality data will be provided for. Must be a
27
+ list of monotonically increasing floats within range 0 <= age <= 200 .
28
+ mortality_rate_matrix: (list[list[float]] or list[float]) A 2-d grid of mortality rates in units of
29
+ "annual death rate for an individual". The first data dimension (index) is by age, the second data
30
+ dimension is by calendar year. For M ages (in years) and N calendars years, the dimensionality of this
31
+ matrix must be MxN . Alternately, a 1-d array of mortality rates may be given and will be interpreted
32
+ as a time-independent "for all time" distribution. This option is only available if the calendar_years
33
+ argument is not used.
34
+ calendar_years: (list[float]) (optional) A list of times (in calendar years) that mortality data will be
35
+ provided for. Must be a list of monotonically increasing floats within range 1900 <= year <= 2200 .
36
+ If not provided, a default single calendar year (1900) will be used that effectively means
37
+ "for all time".
38
+
39
+ Example:
40
+ ages_years: [0, 10, 20, 50, 100] # M ages
41
+ calendar_years: [1950, 1970, 1990] # N times. If not supplied, one time "forever" is used and N below is 1.
42
+ mortality_rate_matrix: dimensionality: MxN
43
+ [[0.2, 0.15, 0.1 ], # These are mortality rates at age 0, the three time points above
44
+ [0.12, 0.08, 0.06], # These are mortality rates at age 10
45
+ [0.05, 0.03, 0.01], # These are mortality rates at age 20
46
+ [0.15, 0.1, 0.05], # These are mortality rates at age 50
47
+ [0.3, 0.25, 0.2 ]] # These are mortality rates at age 100
48
+
49
+ Mortality at age 5 at 1960, bilinearly interpolated (shown in steps):
50
+ 0.2 + (1960-1950) * ((0.15-0.2) / (1970-1950)) = 0.175 (age 0 mortality rate at 1960)
51
+ 0.12 + (1960-1950) * ((0.08-0.12) / (1970-1950)) = 0.1 (age 10 mortality rate at 1960)
52
+ 0.175 + (5-0) * ((0.1-0.175) / (10-0)) = 0.1375 (age 5 mortality rate at 1960)
53
+ Mortality at age 5 at 2100 (beyond supplied times), bilinearly interpolated: (shown in steps)
54
+ (compute the value at the closest time possible, 1970, then report it for year 2100)
55
+ 0.1 (age 0 mortality rate at 1970 (also 2100))
56
+ 0.06 (age 10 mortality rate at 1970 (also 2100))
57
+ 0.1 + (5-0) * ((0.06-0.1) / (10-0)) = 0.08 (age 5 mortality rate at 1970 (also 2100))
58
+ """
59
+ super().__init__()
60
+ self.ages_years = ages_years
61
+
62
+ if calendar_years is None:
63
+ self.calendar_years = [self._default_calendar_year]
64
+ # Here we convert a 1-d array of values to a (trivial) 2-d array. Only allowed if time not passed.
65
+ if check_dimensionality(data=mortality_rate_matrix, dimensionality=1) is True:
66
+ mortality_rate_matrix = [[item] for item in mortality_rate_matrix]
67
+ else:
68
+ self.calendar_years = calendar_years
69
+ self.mortality_rate_matrix = mortality_rate_matrix
70
+
71
+ # This will convert the object to a mortality dictionary and then validate it reporting object-relevant messages
72
+ self._validate(distribution_dict=self.to_dict(validate=False), source_is_dict=False)
73
+
74
+ @property
75
+ def _population_groups(self):
76
+ return [self.ages_years, self.calendar_years]
77
+
78
+ @classmethod
79
+ def _rate_scale_factor(cls):
80
+ return 1 / 365.0 # convert from per-year to per-day
81
+
82
+ @classmethod
83
+ def _rate_scale_units(cls):
84
+ return "annual death rate for an individual"
85
+
86
+ @property
87
+ def _default_calendar_year(self):
88
+ return 1900
89
+
90
+ @classmethod
91
+ def _axis_names(cls):
92
+ return ['age', 'year']
93
+
94
+ @classmethod
95
+ def _axis_scale_factors(cls):
96
+ return [365.0, 1]
97
+
98
+ def to_dict(self, validate: bool = True) -> dict:
99
+ distribution_dict = {
100
+ 'AxisNames': self._axis_names(),
101
+ 'AxisScaleFactors': self._axis_scale_factors(),
102
+ 'PopulationGroups': self._population_groups,
103
+ 'ResultScaleFactor': self._rate_scale_factor(),
104
+ 'ResultUnits': self._rate_scale_units(),
105
+ 'ResultValues': self.mortality_rate_matrix
106
+ }
107
+ if validate:
108
+ self._validate(distribution_dict=distribution_dict, source_is_dict=False)
109
+ return distribution_dict
110
+
111
+ @classmethod
112
+ def from_dict(cls, distribution_dict: dict):
113
+ cls._validate(distribution_dict=distribution_dict, source_is_dict=True)
114
+ return cls(ages_years=distribution_dict['PopulationGroups'][0],
115
+ mortality_rate_matrix=distribution_dict['ResultValues'],
116
+ calendar_years=distribution_dict['PopulationGroups'][1])
117
+
118
+ # True means message relevant to verifying a mortality dictionary, False means messages relevant to verifying an obj
119
+ _validation_messages = {
120
+ 'fixed_value_check': {
121
+ True: "key: {0} value: {1} does not match expected value: {2}",
122
+ False: None # These are all properties of the obj and cannot be made invalid
123
+ },
124
+ 'population_group_length_check': {
125
+ True: "PopulationGroups expected to be a 2-d array of floats. The first dimension length must be two, but "
126
+ "is length {0}",
127
+ False: None # This is a property of the obj and cannot be made invalid
128
+ },
129
+ 'data_dimensionality_check': {
130
+ True: "ResultValues is expected to be a 2-d matrix of data but it is not.",
131
+ False: "mortality_rate_matrix has an improper dimensionality. It must be a 2-d matrix if calendar_years is "
132
+ "given. If calendar_years is NOT given, it MAY be a 1-d list of values."
133
+ },
134
+ 'data_dimensionality_check_dim0': {
135
+ True: "ResultValues first dimension length {0} does not match the PopulationGroups[0] age bin count {1}",
136
+ False: "mortality_rate_matrix first dimension length: {0} does not match the ages_years length: {1}"
137
+ },
138
+ 'data_dimensionality_check_dim1': {
139
+ True: "ResultValues second dimension length {0} does not match the PopulationGroups[1] time bin count: {1}",
140
+ False: "mortality_rate_matrix second dimension length: {0} does not match the calendar_years length: {1}"
141
+ },
142
+ 'age_range_check': {
143
+ True: "PopulationGroups[0] age values must be: 0 <= age <= 200 in years",
144
+ False: "All ages_years values must be: 0 <= age <= 200 in years"
145
+ },
146
+ 'time_range_check': {
147
+ True: "PopulationGroups[1] time values must be: 1900 <= time <= 2200 calendar year",
148
+ False: "All calendar_years values must be: 1900 <= time <= 2200 calendar year"
149
+ },
150
+ 'age_monotonicity_check': {
151
+ True: "PopulationGroups[0] ages in years must monotonically increase but do not, index: {0} value: {1}",
152
+ False: "ages_years values must monotonically increase but do not, index: {0} value: {1}"
153
+ },
154
+ 'time_monotonicity_check': {
155
+ True: "PopulationGroups[1] times in calendar years must monotonically increase but do not, index: {0} value: {1}",
156
+ False: "calendar_years values must monotonically increase but do not, index: {0} value: {1}"
157
+ }
158
+ }
159
+
160
+ @classmethod
161
+ def _validate(cls, distribution_dict: dict, source_is_dict: bool):
162
+ """
163
+ Validate a MortalityDistribution in dict form
164
+
165
+ Args:
166
+ distribution_dict: (dict) the mortality dict to validate
167
+ source_is_dict: (bool) If true, report dict-relevant error messages. If false, report obj-relevant messages.
168
+
169
+ Returns:
170
+ Nothing
171
+ """
172
+ if source_is_dict is True:
173
+ expected_values = {
174
+ 'AxisNames': cls._axis_names(),
175
+ 'AxisScaleFactors': cls._axis_scale_factors(),
176
+ 'ResultScaleFactor': cls._rate_scale_factor(),
177
+ 'ResultUnits': cls._rate_scale_units()
178
+ }
179
+ for key, expected_value in expected_values.items():
180
+ value = distribution_dict[key]
181
+ if value != expected_value:
182
+ message = cls._validation_messages['fixed_value_check'][source_is_dict].format(key, value, expected_value)
183
+ raise demog_ex.InvalidFixedValueException(message)
184
+
185
+ # ensure the data table is MxN for the population groups == [M, N]
186
+ population_groups = distribution_dict['PopulationGroups']
187
+ data_table = distribution_dict['ResultValues']
188
+ if source_is_dict is True:
189
+ if len(population_groups) != 2:
190
+ message = cls._validation_messages['population_group_length_check'][source_is_dict].format(len(population_groups))
191
+ raise demog_ex.InvalidPopulationGroupLengthException(message)
192
+
193
+ # ensure the data table has the correct dimensionality. It must be 2-d.
194
+ is_2d = check_dimensionality(data=data_table, dimensionality=2)
195
+ if not is_2d:
196
+ message = cls._validation_messages['data_dimensionality_check'][source_is_dict]
197
+ raise demog_ex.InvalidDataDimensionality(message)
198
+
199
+ # continue checking dimension lengths
200
+ ages = population_groups[0]
201
+ times = population_groups[1]
202
+ n_ages = len(ages)
203
+ n_times = len(times)
204
+ if len(data_table) != n_ages:
205
+ message = cls._validation_messages['data_dimensionality_check_dim0'][source_is_dict].format(len(data_table), n_ages)
206
+ raise demog_ex.InvalidDataDimensionDim0Exception(message)
207
+ for i in range(len(data_table)):
208
+ if len(data_table[i]) != n_times:
209
+ message = cls._validation_messages['data_dimensionality_check_dim1'][source_is_dict].format(len(data_table[i]), n_times)
210
+ raise demog_ex.InvalidDataDimensionDim1Exception(message)
211
+
212
+ # ensure the age and time lists are ascending and in reasonable ranges
213
+ if any([(age < 0) or (age > 200) for age in ages]):
214
+ message = cls._validation_messages['age_range_check'][source_is_dict]
215
+ raise demog_ex.AgeOutOfRangeException(message)
216
+ if any([(time < 1900) or (time > 2200) for time in times]):
217
+ message = cls._validation_messages['time_range_check'][source_is_dict]
218
+ raise demog_ex.TimeOutOfRangeException(message)
219
+
220
+ for i in range(1, len(ages)):
221
+ if ages[i] - ages[i - 1] <= 0:
222
+ message = cls._validation_messages['age_monotonicity_check'][source_is_dict].format(i, ages[i])
223
+ raise demog_ex.NonMonotonicAgeException(message)
224
+ for i in range(1, len(times)):
225
+ if times[i] - times[i - 1] <= 0:
226
+ message = cls._validation_messages['time_monotonicity_check'][source_is_dict].format(i, times[i])
227
+ raise demog_ex.NonMonotonicTimeException(message)