Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
204d0be
fixing up distribution tests from emodpy
ckirkman-IDM Nov 13, 2025
9428b2d
Fixing up IndividualProperties to properly handle a None (no IPs) con…
ckirkman-IDM Nov 13, 2025
119abee
Big cleanup, removed demog templates, old dist objs, reenabling from_…
ckirkman-IDM Nov 19, 2025
0ace70b
restoring migration tests after having restored demog,from_file(). Re…
ckirkman-IDM Nov 19, 2025
a2cb1c8
doc fixes and linting
ckirkman-IDM Nov 19, 2025
d080adf
Demographics.from_file() now sets implicit functions on its return ob…
ckirkman-IDM Nov 20, 2025
f30e770
merging from mercury
ckirkman-IDM Nov 20, 2025
7125f4e
fixing minor merge error, param rename
ckirkman-IDM Nov 20, 2025
7b4948a
Merge branch 'mercury' into 43
ckirkman-IDM Nov 20, 2025
6eb5668
linting
ckirkman-IDM Nov 20, 2025
d680a0f
Simplify schema processing of interventions; add tests (#49)
kfrey-idm Nov 21, 2025
ae4a61e
Bump version: 2.0.36 → 2.0.37
Nov 21, 2025
a93ee9e
added google tag manager for analytics tracking (#53)
JSchripsema-IDM Nov 22, 2025
fcfa59a
removing ability to pass in unverified metadata to demographics objec…
ckirkman-IDM Nov 25, 2025
3c582dd
minor file_name -> filename usage homogenization
ckirkman-IDM Nov 25, 2025
dbebc43
Change tracking_id to property in mkdocs.yml (#54)
JSchripsema-IDM Nov 25, 2025
43703f8
adding note that susceptibility distributions are not compatible with…
ckirkman-IDM Nov 25, 2025
381c0ab
adding note that susceptibility distributions are not compatible with…
ckirkman-IDM Nov 25, 2025
186f242
removing e.g. usage
ckirkman-IDM Nov 25, 2025
c5c159b
minor doc update
ckirkman-IDM Nov 25, 2025
b68ae0f
distribution explanatory note
ckirkman-IDM Nov 25, 2025
8ab7f4e
minor comment fix
ckirkman-IDM Nov 26, 2025
71f93ac
cleanup of cruft and a touch of named parameters for clarity
ckirkman-IDM Nov 26, 2025
5837553
Clean up docs (#56)
kfrey-idm Nov 26, 2025
167b65c
Bump version: 2.0.37 → 2.0.38
Nov 26, 2025
568cb5c
demographics.to_file() indent is now optional, where None means one-l…
ckirkman-IDM Dec 1, 2025
4d420d1
adding Path to demographics.to_file() type hinting
ckirkman-IDM Dec 1, 2025
ab49bbe
review updates
ckirkman-IDM Dec 2, 2025
eded5d2
changing Demgoraphics.from_file() to use 'r' reading instead of 'rb'.…
ckirkman-IDM Dec 2, 2025
9028fd0
further extending Demographics.from_file() warning
ckirkman-IDM Dec 2, 2025
d9b7252
Updating review comments related to hiv/malaria only comment sections…
ckirkman-IDM Dec 2, 2025
e17b450
Setting both a simple and complex distribution for those that allow e…
ckirkman-IDM Dec 4, 2025
5b4cc8f
final cleanup from review comments
ckirkman-IDM Dec 4, 2025
de9668c
linting
ckirkman-IDM Dec 4, 2025
2e97a01
Merge branch 'main' into mercury
ckirkman-IDM Dec 4, 2025
dbe35d3
Merge branch 'mercury' into 43
ckirkman-IDM Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 0 additions & 59 deletions emod_api/demographics/age_distribution_old.py

This file was deleted.

1 change: 0 additions & 1 deletion emod_api/demographics/base_input_file.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from abc import ABCMeta, abstractmethod
from datetime import datetime

# from simtools.Utilities.LocalOS import LocalOS
import getpass


Expand Down
159 changes: 159 additions & 0 deletions emod_api/demographics/calculators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import math
import numpy as np
import pandas as pd
import os

from scipy import sparse as sp
from scipy.sparse import linalg as la
from typing import Union

from emod_api.demographics.age_distribution import AgeDistribution
from emod_api.demographics.mortality_distribution import MortalityDistribution


def generate_equilibrium_age_distribution(birth_rate: float = 40.0, mortality_rate: float = 20.0) -> AgeDistribution:
"""
Create an AgeDistribution object representing an equilibrium for birth and mortality rates.

Args:
birth_rate: (float) The birth rate in units of births/year/1000-women
mortality_rate: (float) The mortality rate in units of deaths/year/1000 people

Returns:
an AgeDistribution object
"""
from emod_api.demographics.age_distribution import AgeDistribution

# convert to daily rate per person, EMOD units
birth_rate = (birth_rate / 1000) / 365 # what is actually used below
mortality_rate = (mortality_rate / 1000) / 365 # what is actually used below

birth_rate = math.log(1 + birth_rate)
mortality_rate = -1 * math.log(1 - mortality_rate)

# It is important for the age distribution computation that the age-spacing be very fine; I've used 30 days here.
# With coarse spacing, the computation in practice doesn't work as well.
age_dist_tuple = _computeAgeDist(birth_rate, [i * 30 for i in range(1200)], 1200 * [mortality_rate], 12 * [1.0])

# The final demographics file, though, can use coarser binning interpolated from the finely-spaced computed distribution.
age_bins = list(range(16)) + [20 + 5 * i for i in range(14)]
cum_pop_fraction = np.interp(age_bins, [i / 365 for i in age_dist_tuple[2]], age_dist_tuple[1]).tolist()
age_bins.extend([90])
cum_pop_fraction.extend([1.0])
distribution = AgeDistribution(ages_years=age_bins, cumulative_population_fraction=cum_pop_fraction)
return distribution


def _computeAgeDist(bval, mvecX, mvecY, fVec, max_yr=90):
"""
Compute equilibrium age distribution given age-specific mortality and crude birth rates

Args:
bval: crude birth rate in births per day per person
mvecX: list of age bins in days
mvecY: List of per day mortality rate for the age bins
fVec: Seasonal forcing per month
max_yr : maximum agent age in years

returns EquilibPopulationGrowthRate, MonthlyAgeDist, MonthlyAgeBins
author: Kurt Frey
"""

bin_size = 30
day_to_year = 365

# Age brackets
avecY = np.arange(0, max_yr * day_to_year, bin_size) - 1

# Mortality sampling
mvecX = [-1] + mvecX + [max_yr * day_to_year + 1]
mvecY = [mvecY[0]] + mvecY + [mvecY[-1]]
mX = np.arange(0, max_yr * day_to_year, bin_size)
mX[0] = 1
mval = 1.0 - np.interp(mX, xp=mvecX, fp=mvecY)
r_n = mval.size

# Matrix construction
BmatRC = (np.zeros(r_n), np.arange(r_n))
Bmat = sp.csr_matrix(([bval * bin_size] * r_n, BmatRC), shape=(r_n, r_n))
Mmat = sp.spdiags(mval[:-1] ** bin_size, -1, r_n, r_n)
Dmat = Bmat + Mmat

# Math
(gR, popVec) = la.eigs(Dmat, k=1, sigma=1.0)
gR = np.abs(gR ** (float(day_to_year) / float(bin_size)))
popVec = np.abs(popVec) / np.sum(np.abs(popVec))

# Apply seasonal forcing
mVecR = [-2.0, 30.5, 30.6, 60.5, 60.6, 91.5, 91.6, 121.5,
121.6, 152.5, 152.6, 183.5, 183.6, 213.5, 213.6, 244.5,
245.6, 274.5, 274.6, 305.5, 305.6, 333.5, 335.6, 364.5]
fVec = np.flipud([val for val in fVec for _ in (0, 1)])
wfVec = np.array([np.mean(np.interp(np.mod(range(val + 1, val + 31), 365),
xp=mVecR, fp=fVec)) for val in avecY]).reshape(-1, 1)
popVec = popVec * wfVec / np.sum(popVec * wfVec)

# Age sampling
avecY[0] = 0
avecX = np.clip(np.around(np.cumsum(popVec), decimals=7), 0.0, 1.0)
avecX = np.insert(avecX, 0, np.zeros(1))

return gR.tolist()[0], avecX[:-1].tolist(), avecY.tolist()


def generate_mortality_over_time_from_data(data_csv: Union[str, os.PathLike],
base_year: int) -> MortalityDistribution:
"""
Generate a MortalityDistribution object from a data csv file.

Args:
data_csv: Path to csv file with the mortality rates by calendar year and age bucket.
base_year: The calendar year the sim is treating as the base.

Returns:
a MortalityDistribution object.
"""
if base_year < 0:
raise ValueError(f"User passed negative value of base_year: {base_year}.")
if base_year > 2050:
raise ValueError(f"User passed too large value of base_year: {base_year}.")

# Load csv. Convert rate arrays into DTK-compatiable JSON structures.
rates = [] # array of arrays, but leave that for a minute
df = pd.read_csv(data_csv)
header = df.columns
year_start = int(header[1]) # someone's going to come along with 1990.5, etc. Sigh.
year_end = int(header[-1])
if year_end <= year_start:
raise ValueError(f"Failed check that {year_end} is greater than {year_start} in csv dataset.")
num_years = year_end - year_start + 1
rel_years = list()
for year in range(year_start, year_start + num_years):
mort_data = list(df[str(year)])
rel_years.append(year - base_year)

age_key = None
for trykey in df.keys():
if trykey.lower().startswith("age"):
age_key = trykey
raw_age_bins = list(df[age_key])

if age_key is None:
raise ValueError("Failed to find 'Age_Bin' (or similar) column in the csv dataset. Cannot process.")

age_bins = list()
try:
for age_bin in raw_age_bins:
left_age = float(age_bin.split("-")[0])
age_bins.append(left_age)

except Exception as ex:
raise ValueError(f"Ran into error processing the values in the Age-Bin column. {ex}")

for idx in range(len(age_bins)): # 18 of these
# mort_data is the array of mortality rates (by year bin) for age_bin
mort_data = list(df.transpose()[idx][1:])
rates.append(mort_data) # 28 of these, 1 for each year, eg

distribution = MortalityDistribution(ages_years=age_bins, mortality_rate_matrix=rates, calendar_years=rel_years)
return distribution
58 changes: 46 additions & 12 deletions emod_api/demographics/demographics.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import json
import numpy as np
import pandas as pd

from typing import List
from typing import List, Dict

from emod_api.demographics.demographics_base import DemographicsBase
from emod_api.demographics.node import Node
Expand All @@ -14,7 +13,8 @@ class Demographics(DemographicsBase):
"""
This class is a container of data necessary to produce a EMOD-valid demographics input file.
"""
def __init__(self, nodes: List[Node], idref: str = "Gridded world grump2.5arcmin", default_node: Node = None):
def __init__(self, nodes: List[Node], idref: str = "Gridded world grump2.5arcmin", default_node: Node = None,
metadata: Dict = None, set_defaults: bool = True):
"""
Object representation of an EMOD Demographics input (json) file.

Expand All @@ -23,31 +23,65 @@ def __init__(self, nodes: List[Node], idref: str = "Gridded world grump2.5arcmin
idref: (string) an identifier for the Demographics file. Used to co-identify sets of Demographics/overlay
files.
default_node: (Node) Represents default values for all nodes, unless overridden on a per-node basis.
metadata: (Dict) set the demographics metadata to the supplied dictionary. Default yields default
metadata values.
set_defaults: (bool) Whether to set default node attributes on the default node. Defaults to True.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the documentation for the init() method go above it since it is part of creating an object of the class?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh, I have never put such documentation there. I can put it wherever. It would be good to talk about a team-standard and decide (moving forward). As for here, after taking a look at the below, I'll do whatever.

PEP #257 apparently indicates init() is the "proper" place for this. It is the "most liked" solution in this relevant stackoverflow discussion, but by not means the only solution proposed/discussed.

https://stackoverflow.com/questions/37019744/is-there-a-consensus-on-what-should-be-documented-in-the-class-and-init-docs

"""
super().__init__(nodes=nodes, idref=idref, default_node=default_node)
super().__init__(nodes=nodes, idref=idref, default_node=default_node, metadata=metadata)

# set some standard EMOD defaults
self.default_node.node_attributes.airport = 1
self.default_node.node_attributes.seaport = 1
self.default_node.node_attributes.region = 1
if set_defaults:
self.default_node.node_attributes.airport = 1
self.default_node.node_attributes.seaport = 1
self.default_node.node_attributes.region = 1

def to_file(self, name: str = "demographics.json") -> None:
def to_file(self, path: str = "demographics.json") -> None:
"""
Write the Demographics object to an EMOD demograhpics json file.

Args:
name: (str) the filepath to write the file to. Default is "demographics.json".
path: (str) the filepath to write the file to. Default is "demographics.json".

Returns:
Nothing
"""
with open(name, "w") as output:
with open(path, "w") as output:
json.dump(self.to_dict(), output, indent=3, sort_keys=True)

def generate_file(self, name: str = "demographics.json"):
def generate_file(self, path: str = "demographics.json"):
import warnings
warnings.warn("generate_file() is deprecated. Please use to_file()", DeprecationWarning, stacklevel=2)
self.to_file(name=name)
self.to_file(path=path)

@classmethod
def from_file(cls, path: str) -> "Demographics":
"""
Create a Demographics object from an EMOD-compatible demographics json file.

Args:
path (str): the file path to read from.:

Returns:
a Demographics object
"""

with open(path, "rb") as src:
demographics_dict = json.load(src)
demographics_dict["Defaults"]["NodeID"] = 0 # This is a requirement of all emod-api Demographics objects
implicit_functions = []
nodes = []
for node_dict in demographics_dict["Nodes"]:
node, implicits = Node.from_data(data=node_dict)
implicit_functions.extend(implicits)
nodes.append(node)
default_node, implicits = Node.from_data(data=demographics_dict["Defaults"])
implicit_functions.extend(implicits)
metadata = demographics_dict["Metadata"]
idref = demographics_dict["Metadata"]["IdReference"]

demographics = cls(nodes=nodes, default_node=default_node, idref=idref, metadata=metadata, set_defaults=False)
demographics.implicits.extend(implicit_functions)
return demographics

@classmethod
def from_template_node(cls,
Expand Down
Loading