diff --git a/resources/ResourceFile_Wasting.csv b/resources/ResourceFile_Wasting.csv new file mode 100644 index 0000000000..e4c7d87238 --- /dev/null +++ b/resources/ResourceFile_Wasting.csv @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbd6e80601a7ed0343f1d143ea028be4046615d0323e5250fa3aed69d5028940 +size 2927 diff --git a/resources/healthsystem/priority_policies/ResourceFile_PriorityRanking_ALLPOLICIES.xlsx b/resources/healthsystem/priority_policies/ResourceFile_PriorityRanking_ALLPOLICIES.xlsx index 1748d3f5e9..b8d2fa1d8f 100644 --- a/resources/healthsystem/priority_policies/ResourceFile_PriorityRanking_ALLPOLICIES.xlsx +++ b/resources/healthsystem/priority_policies/ResourceFile_PriorityRanking_ALLPOLICIES.xlsx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59a7b6737589f04bc5fc80c3ca3c60f6dae2e1cf95e41ebefa995294298fbc84 -size 42958 +oid sha256:5d1a13f50e13f26aa00f5588cd012a8599ac558b7ac10fb427639a2987856ab9 +size 41075 diff --git a/src/scripts/undernutrition_analyses/stunting/stunting_analysis_plots.py b/src/scripts/stunting_analyses/stunting/stunting_analysis_plots.py similarity index 100% rename from src/scripts/undernutrition_analyses/stunting/stunting_analysis_plots.py rename to src/scripts/stunting_analyses/stunting/stunting_analysis_plots.py diff --git a/src/scripts/undernutrition_analyses/stunting/stunting_analysis_scenario.py b/src/scripts/stunting_analyses/stunting/stunting_analysis_scenario.py similarity index 93% rename from src/scripts/undernutrition_analyses/stunting/stunting_analysis_scenario.py rename to src/scripts/stunting_analyses/stunting/stunting_analysis_scenario.py index 86cb03414c..04f02ce2aa 100644 --- a/src/scripts/undernutrition_analyses/stunting/stunting_analysis_scenario.py +++ b/src/scripts/stunting_analyses/stunting/stunting_analysis_scenario.py @@ -3,10 +3,10 @@ HealthSystem availability - including the effects of Diarrhoea and Alri and all the Labour modules. Run on the batch system using: -```tlo batch-submit src/scripts/undernutrition_analyses/stunting/stunting_analysis_scenario.py``` +```tlo batch-submit src/scripts/stunting_analyses/stunting/stunting_analysis_scenario.py``` Or locally using: -```tlo scenario-run src/scripts/undernutrition_analyses/stunting/stunting_analysis_scenario.py``` +```tlo scenario-run src/scripts/stunting_analyses/stunting/stunting_analysis_scenario.py``` """ from pathlib import Path diff --git a/src/scripts/wasting_analyses/analysis_wasting.py b/src/scripts/wasting_analyses/analysis_wasting.py new file mode 100644 index 0000000000..ffc8bf5ea0 --- /dev/null +++ b/src/scripts/wasting_analyses/analysis_wasting.py @@ -0,0 +1,452 @@ +""" +An analysis file for the wasting module (so far only for 1 run, 1 draw) +""" +# %% Import statements +import glob +import gzip +import os +import shutil +import time +from pathlib import Path + +import pandas as pd +from matplotlib import pyplot as plt +from PyPDF2 import PdfReader, PdfWriter + +from tlo.analysis.utils import compare_number_of_deaths, get_scenario_outputs, parse_log_file + +# start time of the analysis +time_start = time.time() + +# ####### TO SET ####################################################################################################### +scenario_filename = 'wasting_analysis__minimal_model' +outputs_path = Path("./outputs/sejjej5@ucl.ac.uk/wasting") +######################################################################################################################## + + +class WastingAnalyses: + """ + This class looks at plotting all important outputs from the wasting module + """ + + def __init__(self, in_scenario_filename, in_outputs_path): + + # Find sim_results_folder associated with a given batch_file (and get most recent [-1]) + sim_results_folder = get_scenario_outputs(in_scenario_filename, in_outputs_path)[-1] + sim_results_parent_folder_name = str(sim_results_folder.parent) + sim_results_folder_name = sim_results_folder.name + self.outcomes_path_name = str(in_outputs_path) + "/" + sim_results_folder_name + # Get the datestamp + if sim_results_folder_name.startswith(scenario_filename + '-'): + self.datestamp = sim_results_folder_name[(len(scenario_filename)+1):] + else: + print("The scenario output name does not correspond with the set scenario_filename.") + + # Path to the .log.gz file + sim_results_folder_path_run0_draw0 = sim_results_parent_folder_name + '/' + sim_results_folder_name + '/0/0/' + sim_results_file_name_prefix = scenario_filename + sim_results_file_name_extension = '.log.gz' + gz_results_file_path = \ + Path(glob.glob(os.path.join(sim_results_folder_path_run0_draw0, + f"{sim_results_file_name_prefix}*{sim_results_file_name_extension}"))[0]) + + # Path to the decompressed .log file + log_results_file_path = gz_results_file_path.with_suffix('') + + # Decompress the .log.gz file + with gzip.open(gz_results_file_path, 'rb') as f_in: + with open(log_results_file_path, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + + self.__log_file_path = log_results_file_path + # parse wasting logs + self.__w_logs_dict = parse_log_file(self.__log_file_path)['tlo.methods.wasting'] + # parse scaling factor log + self.__scaling_factor = \ + parse_log_file(self.__log_file_path)['tlo.methods.population']['scaling_factor'].set_index('date').loc[ + '2010-01-01', 'scaling_factor' + ] + + # gender description + self.__gender_desc = {'M': 'Males', + 'F': 'Females'} + + # wasting types description + self.__wasting_types_desc = {'WHZ<-3': 'severe wasting', + '-3<=WHZ<-2': 'moderate wasting', + 'WHZ>=-2': 'not undernourished'} + + self.fig_files = [] + + cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] + # # define colo(u)rs to use: + self.__colors = { + 'severe wasting': cycle[0], + 'moderate wasting': cycle[1], + 'SAM': cycle[2], + 'MAM': cycle[3], + } + + def save_fig__store_pdf_file(self, fig, fig_output_name: str) -> None: + fig.savefig(self.outcomes_path_name + "/" + fig_output_name + '.png', format='png') + fig.savefig(self.outcomes_path_name + "/" + fig_output_name + '.pdf', format='pdf') + self.fig_files.append(fig_output_name + '.pdf') + + def plot_wasting_incidence(self): + """ plot the incidence of wasting over time """ + w_inc_df = self.__w_logs_dict['wasting_incidence_count'] + w_inc_df = w_inc_df.set_index(w_inc_df.date.dt.year) + w_inc_df = w_inc_df.drop(columns='date') + # check no incidence of well-nourished + all_zeros = w_inc_df['WHZ>=-2'].apply(lambda x: all(value == 0 for value in x.values())) + assert all(all_zeros) + w_inc_df = w_inc_df[["WHZ<-3", "-3<=WHZ<-2"]] + # get age_years, doesn't matter what wasting category you choose, + # they all have same age groups + age_years = list(w_inc_df.loc[w_inc_df.index[0], 'WHZ<-3'].keys( + + )) + # age_years.remove('5+y') + + _row_counter = 0 + _col_counter = 0 + # plot setup + fig, axes = plt.subplots(nrows=2, ncols=3, sharex=True, sharey=True, figsize=(10, 6)) + axes[1, 2].axis('off') # 5+y has no data (no new cases in 5+y), its space is used to display the label + for _age in age_years: + plotting = pd.DataFrame() + for state in w_inc_df.columns: + plotting[state] = \ + w_inc_df.apply(lambda row: row[state][_age], axis=1) + # remove sev cases from mod cases (all sev cases went through mod state) + plotting["-3<=WHZ<-2"] = plotting["-3<=WHZ<-2"] - plotting["WHZ<-3"] + # rescale nmbs from simulated pop_size to pop size of Malawi + plotting = plotting * self.__scaling_factor + plotting = plotting.rename(columns=self.__wasting_types_desc) + + ax = plotting.plot(kind='bar', stacked=True, + ax=axes[_row_counter, _col_counter], + title=f"incidence of wasting in {_age} old")#, + #ylim=[0, 1]) + show_legend = (_row_counter == 1 and _col_counter == 2) + # show_x_axis_label = (_row_counter == 0 and _col_counter == 2) + if show_legend: + ax.legend(loc='center') + ax.set_title('') + else: + ax.get_legend().remove() + # if show_x_axis_label: + # ax.set_xlabel('Year') # TODO: this is not working + ax.set_xlabel('year') + ax.set_ylabel('number of incidence cases') + # move to another row + if _col_counter == 2: + _row_counter += 1 + _col_counter = -1 + _col_counter += 1 # increment column counter + fig.tight_layout() + fig_output_name = ('wasting_incidence__' + self.datestamp) + self.save_fig__store_pdf_file(fig, fig_output_name) + # plt.show() + + # def plot_wasting_incidence_mod_to_sev_props(self): + # """ plot the incidence of wasting over time """ + # w_inc_df = self.__w_logs_dict['wasting_incidence_count'] + # w_inc_df = w_inc_df.set_index(w_inc_df.date.dt.year) + # w_inc_df = w_inc_df.drop(columns='date') + # # check no incidence of well-nourished + # all_zeros = w_inc_df['WHZ>=-2'].apply(lambda x: all(value == 0 for value in x.values())) + # assert all(all_zeros) + # w_inc_df = w_inc_df[["WHZ<-3", "-3<=WHZ<-2"]] + # # get age_years, doesn't matter what wasting category you choose, + # # they all have same age groups + # age_years = list(w_inc_df.loc[w_inc_df.index[0], 'WHZ<-3'].keys( + # + # )) + # age_years.remove('5+y') + # + # _row_counter = 0 + # _col_counter = 0 + # # plot setup + # fig, axes = plt.subplots(nrows=2, ncols=3, sharex=True, sharey=True, figsize=(10, 6)) + # fig.delaxes(axes[1, 2]) + # for _age in age_years: + # new_df = pd.DataFrame() + # for state in w_inc_df.columns: + # new_df[state] = \ + # w_inc_df.apply(lambda row: row[state][_age], axis=1) + # # convert into proportions + # new_df = new_df.apply(lambda _row: _row / _row.sum(), axis=1) + # plotting = new_df.rename(columns=self.__wasting_types_desc) + # ax = plotting.plot(kind='bar', stacked=True, + # ax=axes[_row_counter, _col_counter], + # title=f"incidence of wasting in {_age} old", + # ylim=[0, 1]) + # ax.legend(loc='lower right') + # ax.set_xlabel('year') + # ax.set_ylabel('proportion') + # # move to another row + # if _col_counter == 2: + # _row_counter += 1 + # _col_counter = -1 + # _col_counter += 1 # increment column counter + # + # handles, labels = axes[1, 1].get_legend_handles_labels() + # fig.legend(handles, labels, loc='center left', bbox_to_anchor=(1.05, 0.5)) + # fig_output_name = ('wasting_incidence_mod_to_sev_props__' + self.datestamp) + # fig.tight_layout() + # self.save_fig__store_pdf_file(fig, fig_output_name) + # # plt.show() + + def plot_wasting_length(self): + """ plot the average length of wasting over time """ + w_length_df = self.__w_logs_dict['wasting_length_avg'] + w_length_df = w_length_df.set_index(w_length_df.date.dt.year) + w_length_df = w_length_df.drop(columns='date') + # get age_years, doesn't matter from which dict + age_years = list(w_length_df.loc[w_length_df.index[0], 'mod_MAM_tx_full_recov'].keys()) + # age_years.remove('5+y') + w_length_df = w_length_df.loc[:, ['mod_nat_recov', 'mod_MAM_tx_full_recov', 'mod_SAM_tx_full_recov', + 'mod_SAM_tx_recov_to_MAM', 'mod_not_yet_recovered', + 'sev_SAM_tx_full_recov', 'sev_SAM_tx_recov_to_MAM', + 'sev_not_yet_recovered']] + + for recov_opt in w_length_df.columns: + _row_counter = 0 + _col_counter = 0 + # plot setup + fig, axes = plt.subplots(nrows=2, ncols=3, sharex=True, sharey=True, figsize=(10, 7)) + # axes[1, 2].axis('off') # 5+y has no data (no new cases in 5+y), its space is used to display the label + for _age in age_years: + plotting = pd.DataFrame() + # dict to dataframe + plotting[recov_opt] = \ + w_length_df.apply(lambda row: row[recov_opt][_age], axis=1) + + if recov_opt.startswith("mod_"): + colour_to_use = self.__colors['moderate wasting'] + y_upper_lim = 355 + else: + colour_to_use = self.__colors['severe wasting'] + y_upper_lim = 1000 + if recov_opt.endswith("not_yet_recovered"): + y_upper_lim = 4000 + + ax = plotting.plot(kind='bar', stacked=False, + ax=axes[_row_counter, _col_counter], + title=f"length of wasting in {_age} old", + color=colour_to_use, + ylim=[0, y_upper_lim]) + # show_legend = (_row_counter == 0 and _col_counter == 0) + # # show_x_axis_label = (_row_counter == 0 and _col_counter == 2) + # if show_legend: + # ax.legend(loc='upper right', bbox_to_anchor=(0.5, 1.2), + # fancybox=True, shadow=True, ncol=5) + # else: + ax.get_legend().remove() + # if show_x_axis_label: + # ax.set_xlabel('Year') # TODO: this is not working + ax.set_xlabel('year') + ax.set_ylabel('avg length of wasting (days)') + # move to another row + if _col_counter == 2: + _row_counter += 1 + _col_counter = -1 + _col_counter += 1 # increment column counter + + fig.suptitle(f'{recov_opt}', fontsize=16) + # Adjust layout to make room for the suptitle + fig.tight_layout(rect=[0, 0, 1, 0.95]) + fig_output_name = ('wasting_length__' + recov_opt + self.datestamp) + self.save_fig__store_pdf_file(fig, fig_output_name) + # plt.show() + + def plot_wasting_prevalence_per_year(self): + """ plot wasting prevalence of all age groups per year. Proportions are obtained by getting a total number of + children wasted divide by the total number of children less than 5 years""" + w_prev_df = self.__w_logs_dict["wasting_prevalence_props"] + w_prev_df = w_prev_df[['date', 'total_sev_under5_prop', 'total_mod_under5_prop']] + w_prev_df = w_prev_df.set_index(w_prev_df.date.dt.year) + w_prev_df = w_prev_df.drop(columns='date') + fig, ax = plt.subplots() + w_prev_df.plot(kind='bar', stacked=True, + ax=ax, + title="Wasting prevalence in children 0-59 months per year", + ylabel='proportion of wasted children in the year', + xlabel='year', + ylim=[0, 0.15]) + # add_footnote(fig, "proportion of wasted children within each age-group") + plt.tight_layout() + fig_output_name = ('wasting_prevalence_per_year__' + self.datestamp) + self.save_fig__store_pdf_file(fig, fig_output_name) + # plt.show() + + def plot_wasting_prevalence_by_age_group(self): + """ Plot wasting prevalence per each age group. Proportions are obtained by getting a total number of + children wasted in a particular age-group divided by the total number of children per that age-group""" + w_prev_df = self.__w_logs_dict["wasting_prevalence_props"] + w_prev_df = w_prev_df.drop(columns={'total_mod_under5_prop', 'total_sev_under5_prop'}) + w_prev_df = w_prev_df.set_index(w_prev_df.date.dt.year) + w_prev_df = w_prev_df.loc[w_prev_df.index == 2020] + w_prev_df = w_prev_df.drop(columns='date') + plotting = {'severe wasting': {}, 'moderate wasting': {}} + for col in w_prev_df.columns: + prefix, age_group = col.split('__') + if prefix == 'sev': + plotting['severe wasting'][age_group] = w_prev_df[col].values[0] + elif prefix == 'mod': + plotting['moderate wasting'][age_group] = w_prev_df[col].values[0] + plotting = pd.DataFrame(plotting) + order_x_axis = ['0_5mo', '6_11mo', '12_23mo', '24_35mo', '36_47mo', '48_59mo', '5y+'] + # Assert all age groups are included + assert set(plotting.index) == set(order_x_axis), "age groups are not in line with the order_x_axis." + plotting = plotting.reindex(order_x_axis) + + # Plot wasting prevalence + fig, ax = plt.subplots(figsize=(10, 6)) + plotting.squeeze().plot(kind='bar', stacked=True, + ax=ax, + title="Wasting prevalence in children 0-59 months per each age group in 2020", + ylabel='proportion', + xlabel='age group', + ylim=[0, 0.2]) + # Adjust the layout to make space for the footnote + plt.subplots_adjust(bottom=0.85) # Adjust the bottom margin + # Add footnote + fig.figure.text(0.45, 0.88, + "proportion = number of wasted children in the age group " + "/ total number of children in the age group", + ha="center", fontsize=10, bbox={"facecolor": "gray", "alpha": 0.3, "pad": 5}) + plt.tight_layout() + fig_output_name = ('wasting_prevalence_per_each_age_group__' + self.datestamp) + self.save_fig__store_pdf_file(fig, fig_output_name) + # plt.show() + + def plot_wasting_initial_prevalence_by_age_group(self): + """ Plot wasting prevalence per each age group. Proportions are obtained by getting a total number of + children wasted in a particular age-group divided by the total number of children per that age-group""" + w_prev_df = self.__w_logs_dict["wasting_init_prevalence_props"] + w_prev_df = w_prev_df.drop(columns={'total_mod_under5_prop', 'total_sev_under5_prop'}) + w_prev_df = w_prev_df.set_index(w_prev_df.date.dt.year) + w_prev_df = w_prev_df.drop(columns='date') + plotting = {'severe wasting': {}, 'moderate wasting': {}} + for col in w_prev_df.columns: + prefix, age_group = col.split('__') + if prefix == 'sev': + plotting['severe wasting'][age_group] = w_prev_df[col].values[0] + elif prefix == 'mod': + plotting['moderate wasting'][age_group] = w_prev_df[col].values[0] + plotting = pd.DataFrame(plotting) + order_x_axis = ['0_5mo', '6_11mo', '12_23mo', '24_35mo', '36_47mo', '48_59mo', '5y+'] + # Assert all age groups are included + assert set(plotting.index) == set(order_x_axis), "age groups are not in line with the order_x_axis." + plotting = plotting.reindex(order_x_axis) + + # Plot wasting prevalence + fig, ax = plt.subplots(figsize=(10, 6)) + plotting.squeeze().plot(kind='bar', stacked=True, + ax=ax, + ylabel='proportion', + xlabel='age group', + ylim=[0, 0.2]) + ax.set_title(r"Wasting prevalence in children 0-59 months per each age group $\bf{at}$ $\bf{initiation}$") + # Adjust the layout to make space for the footnote + plt.subplots_adjust(bottom=0.85) # Adjust the bottom margin + # Add footnote + fig.figure.text(0.45, 0.88, + "proportion = number of wasted children in the age group " + "/ total number of children in the age group", + ha="center", fontsize=10, bbox={"facecolor": "gray", "alpha": 0.3, "pad": 5}) + plt.tight_layout() + fig_output_name = ('wasting_initial_prevalence_per_each_age_group__' + self.datestamp) + self.save_fig__store_pdf_file(fig, fig_output_name) + # plt.show() + + def add_wasting_initial_prevalence_by_age_group(self): + self.fig_files.append('wasting_initial_prevalence_per_each_age_group__' + self.datestamp + '.pdf') + + def plot_modal_gbd_deaths_by_gender(self): + """ compare modal and GBD deaths by gender """ + death_compare = \ + compare_number_of_deaths(self.__log_file_path, resources_path) + fig, ax = plt.subplots(figsize=(10, 6)) + plot_df = death_compare.loc[(['2010-2014', '2015-2019'], + slice(None), slice(None), 'Childhood Undernutrition' + )].groupby('period').sum() + plotting = plot_df.loc[['2010-2014', '2015-2019']] + ax = plotting['model'].plot.bar(label='Model', ax=ax, rot=0) + ax.errorbar(x=plotting['model'].index, y=plotting.GBD_mean, + yerr=[plotting.GBD_lower, plotting.GBD_upper], + fmt='o', color='#000', label="GBD") + ax.set_title('Direct deaths due to severe acute malnutrition') + ax.set_xlabel("time period") + ax.set_ylabel("number of deaths") + ax.legend(loc=2) + fig.tight_layout() + # Adjust the layout to make space for the footnote + plt.subplots_adjust(bottom=0.15) # Adjust the bottom margin + # Add footnote + fig.figure.text(0.5, 0.02, + "Model output against Global Burden of Diseases (GBD) study data", + ha="center", fontsize=10, bbox={"facecolor": "gray", "alpha": 0.3, "pad": 5}) + fig_output_name = ('modal_gbd_deaths_by_gender__' + self.datestamp) + self.save_fig__store_pdf_file(fig, fig_output_name) + # plt.show() + + def plot_all_figs_in_one_pdf(self): + + output_file_path = Path(self.outcomes_path_name + '/wasting_all_figures__' + self.datestamp + '.pdf') + # Remove the existing output file if it exists to ensure a clean start + if os.path.exists(output_file_path): + os.remove(output_file_path) + + # Assert that the file doesn't exist anymore after removal + assert not os.path.exists(output_file_path), "The file was not successfully removed." + + # Merge the PDF files + # Create a PDF writer object + pdf_writer = PdfWriter() + + # Iterate through the figure files and add each to the writer + for fig_file in self.fig_files: + pdf_reader = PdfReader(self.outcomes_path_name + "/" + fig_file) + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + pdf_writer.add_page(page) + + # Write the merged PDF to a file + with open(output_file_path, 'wb') as out_file: + pdf_writer.write(out_file) + + +if __name__ == "__main__": + + # Path to the resource files used by the disease and intervention methods + resources_path = Path("./resources") + + # initialise the wasting class + wasting_analyses = WastingAnalyses(scenario_filename, outputs_path) + + # plot wasting incidence + wasting_analyses.plot_wasting_incidence() + + # plot wasting incidence mod:sev proportions + # wasting_analyses.plot_wasting_incidence_mod_to_sev_props() + + # plot wasting length + wasting_analyses.plot_wasting_length() + + # plot wasting prevalence + wasting_analyses.plot_wasting_prevalence_per_year() + + # plot wasting prevalence by age group + wasting_analyses.plot_wasting_prevalence_by_age_group() + + # plot wasting initial prevalence by age group + wasting_analyses.plot_wasting_initial_prevalence_by_age_group() + + # plot wasting deaths by gender as compared to GBD deaths + wasting_analyses.plot_modal_gbd_deaths_by_gender() + + # save all figures in one pdf + wasting_analyses.plot_all_figs_in_one_pdf() diff --git a/src/scripts/wasting_analyses/analysis_wasting_with_sim_running.py b/src/scripts/wasting_analyses/analysis_wasting_with_sim_running.py new file mode 100644 index 0000000000..b6d6480c91 --- /dev/null +++ b/src/scripts/wasting_analyses/analysis_wasting_with_sim_running.py @@ -0,0 +1,252 @@ +""" +An analysis file for the wasting module +""" +import datetime +# %% Import statements +from pathlib import Path + +import pandas as pd +from matplotlib import pyplot as plt + +from tlo import Date, Simulation, logging +from tlo.analysis.utils import compare_number_of_deaths, parse_log_file +from tlo.methods import ( + care_of_women_during_pregnancy, + contraception, + demography, + enhanced_lifestyle, + epi, + healthburden, + healthseekingbehaviour, + healthsystem, + hiv, + labour, + newborn_outcomes, + postnatal_supervisor, + pregnancy_supervisor, + symptommanager, + tb, + wasting, +) + + +def add_footnote(fig: plt.Figure, footnote: str): + """ A function that adds a footnote below each plot. Here we are explaining what a denominator for every + graph is """ + fig.figure.text(0.5, 0.01, footnote, ha="center", fontsize=10, + bbox={"facecolor": "gray", "alpha": 0.3, "pad": 5}) + + +class WastingAnalyses: + """ + This class looks at plotting all important outputs from the wasting module + """ + + def __init__(self, log_file_path): + self.__log_file_path = log_file_path + # parse wasting logs + self.__logs_dict = \ + parse_log_file(self.__log_file_path)['tlo.methods.wasting'] + + # gender description + self.__gender_desc = {'M': 'Males', + 'F': 'Females'} + + # wasting types description + self.__wasting_types_desc = {'WHZ<-3': 'severe wasting', + '-3<=WHZ<-2': 'moderate wasting', + 'WHZ>=-2': 'not undernourished'} + + def plot_wasting_incidence(self): + """ plot the incidence of wasting over time """ + w_inc_df = self.__logs_dict['wasting_incidence_count'] + w_inc_df.set_index(w_inc_df.date.dt.year, inplace=True) + w_inc_df.drop(columns='date', inplace=True) + # get age year. doesn't matter what wasting category you choose for + # they all have same age groups + age_years = list(w_inc_df.loc[w_inc_df.index[0], 'WHZ<-3'].keys( + + )) + age_years.remove('5+y') + + _row_counter = 0 + _col_counter = 0 + # plot setup + fig, axes = plt.subplots(nrows=2, ncols=3, sharex=True, sharey=True) + for _age in age_years: + new_df = pd.DataFrame() + for state in w_inc_df.columns: + new_df[state] = \ + w_inc_df.apply(lambda row: row[state][_age], axis=1) + + new_df = new_df.apply(lambda _row: _row / _row.sum(), axis=1) + plotting = new_df[["WHZ<-3", "-3<=WHZ<-2"]] + # convert into proportions + ax = plotting.plot(kind='bar', stacked=True, + ax=axes[_row_counter, _col_counter], + title=f"incidence of wasting in {_age} infants", + ylim=[0, 0.05]) + ax.legend(self.__wasting_types_desc.values(), loc='lower right') + ax.set_xlabel('year') + ax.set_ylabel('proportions') + # move to another row + if _col_counter == 2: + _row_counter += 1 + _col_counter = -1 + _col_counter += 1 # increment column counter + fig.tight_layout() + fig.savefig( + outputs / ('wasting incidence' + datestamp + ".pdf"), + format="pdf" + ) + plt.show() + + def plot_wasting_prevalence_per_year(self): + """ plot wasting prevalence of all age groups per year. Proportions are obtained by getting a total number of + children wasted divide by the total number of children less than 5 years""" + w_prev_df = self.__logs_dict["wasting_prevalence_props"] + w_prev_df = w_prev_df[['date', 'total_under5_prop']] + w_prev_df.set_index(w_prev_df.date.dt.year, inplace=True) + w_prev_df.drop(columns='date', inplace=True) + fig, ax = plt.subplots() + w_prev_df["total_under5_prop"].plot(kind='bar', stacked=True, + ax=ax, + title="Wasting prevalence in children 0-59 months per year", + ylabel='proportion of wasted children within the age-group', + xlabel='year', + ylim=[0, 0.05]) + # add_footnote(fig, "proportion of wasted children within each age-group") + plt.tight_layout() + fig.savefig( + outputs / ('wasting_prevalence_per_year' + datestamp + ".pdf"), + format="pdf" + ) + plt.show() + + def plot_wasting_prevalence_by_age_group(self): + """ plot wasting prevalence per each age group. Proportions are obtained by getting a total number of + children wasted in a particular age-group divide by the total number of children per that age-group""" + w_prev_df = self.__logs_dict["wasting_prevalence_props"] + w_prev_df.drop(columns={'total_under5_prop'}, inplace=True) + w_prev_df.set_index(w_prev_df.date.dt.year, inplace=True) + w_prev_df = w_prev_df.loc[w_prev_df.index == 2023] + w_prev_df.drop(columns='date', inplace=True) + fig, ax = plt.subplots() + # plot wasting prevalence + w_prev_df.squeeze().plot(kind='bar', stacked=False, + ax=ax, + title="Wasting prevalence in children 0-59 months per each age group in 2023", + ylabel='proportions', + xlabel='year', + ylim=[0, 0.1]) + add_footnote(fig, "Proportion = total number of wasted children < 5 years per each age-group / total number of " + "children < 5 years per each age-group") + plt.tight_layout() + fig.savefig( + outputs / ('wasting_prevalence_per_each_age_group' + datestamp + ".pdf"), + format="pdf" + ) + plt.show() + + def plot_modal_gbd_deaths_by_gender(self): + """ compare modal and GBD deaths by gender """ + death_compare = \ + compare_number_of_deaths(self.__log_file_path, resources) + fig, axs = plt.subplots(nrows=1, ncols=2, sharey=True, sharex=True) + for _col, sex in enumerate(('M', 'F')): + plot_df = death_compare.loc[(['2010-2014', '2015-2019'], + sex, slice(None), 'Childhood Undernutrition' + )].groupby('period').sum() + plotting = plot_df.loc[['2010-2014', '2015-2019']] + ax = plotting['model'].plot.bar(label='Model', ax=axs[_col], rot=0) + ax.errorbar(x=plotting['model'].index, y=plotting.GBD_mean, + yerr=[plotting.GBD_lower, plotting.GBD_upper], + fmt='o', color='#000', label="GBD") + ax.set_title(f'{self.__gender_desc[sex]} ' + f'wasting deaths, 2010-2014') + ax.set_xlabel("Time period") + ax.set_ylabel("Number of deaths") + ax.legend(loc=2) + fig.tight_layout() + add_footnote(fig, "Model output against Global Burden of Diseases(GDB) study data") + fig.savefig( + outputs / ('modal_gbd_deaths_by_gender' + datestamp + ".pdf"), + format="pdf" + ) + plt.show() + + +if __name__ == "__main__": + seed = 1 + + # Path to the resource files used by the disease and intervention methods + resources = Path("./resources") + outputs = Path("./outputs") + + # create a datestamp + datestamp = datetime.date.today().strftime("__%Y_%m_%d") + \ + datetime.datetime.now().strftime("%H_%M_%S") + + # configure logging + log_config = { + # output filename. A timestamp will be added to this. + "filename": "wasting", + "custom_levels": { # Customise the output of specific loggers + "tlo.methods.demography": logging.INFO, + "tlo.methods.population": logging.INFO, + "tlo.methods.wasting": logging.INFO, + '*': logging.WARNING + } + } + + # Basic arguments required for the simulation + start_date = Date(2010, 1, 1) + end_date = Date(2030, 1, 2) + pop_size = 20000 + + # Create simulation instance for this run. + sim = Simulation(start_date=start_date, seed=seed, log_config=log_config) + + # Register modules for simulation + sim.register( + demography.Demography(resourcefilepath=resources), + healthsystem.HealthSystem(resourcefilepath=resources, + service_availability=['*'], + cons_availability='default'), + healthseekingbehaviour.HealthSeekingBehaviour(resourcefilepath=resources), + healthburden.HealthBurden(resourcefilepath=resources), + symptommanager.SymptomManager(resourcefilepath=resources), + enhanced_lifestyle.Lifestyle(resourcefilepath=resources), + labour.Labour(resourcefilepath=resources), + care_of_women_during_pregnancy.CareOfWomenDuringPregnancy( + resourcefilepath=resources), + contraception.Contraception(resourcefilepath=resources), + pregnancy_supervisor.PregnancySupervisor(resourcefilepath=resources), + postnatal_supervisor.PostnatalSupervisor(resourcefilepath=resources), + newborn_outcomes.NewbornOutcomes(resourcefilepath=resources), + hiv.Hiv(resourcefilepath=resources), + tb.Tb(resourcefilepath=resources), + epi.Epi(resourcefilepath=resources), + wasting.Wasting(resourcefilepath=resources), + ) + + sim.make_initial_population(n=pop_size) + sim.simulate(end_date=end_date) + + # read the results + output_path = sim.log_filepath + + # initialise the wasting class + wasting_analyses = WastingAnalyses(output_path) + + # plot wasting incidence + wasting_analyses.plot_wasting_incidence() + + # plot wasting prevalence + wasting_analyses.plot_wasting_prevalence_per_year() + + # plot wasting prevalence by age group + wasting_analyses.plot_wasting_prevalence_by_age_group() + + # plot wasting deaths by gender as compared to GBD deaths + wasting_analyses.plot_modal_gbd_deaths_by_gender() diff --git a/src/scripts/wasting_analyses/scenarios/scenario_wasting_full_model.py b/src/scripts/wasting_analyses/scenarios/scenario_wasting_full_model.py new file mode 100644 index 0000000000..f4472a6794 --- /dev/null +++ b/src/scripts/wasting_analyses/scenarios/scenario_wasting_full_model.py @@ -0,0 +1,59 @@ +""" +This file defines a scenario for wasting analysis. + +It can be submitted on Azure Batch by running: + + tlo batch-submit src/scripts/wasting_analyses/scenarios/scenario_wasting_full_model.py + +or locally using: + + tlo scenario-run src/scripts/wasting_analyses/scenarios/scenario_wasting_full_model.py +""" +import warnings + +from tlo import Date, logging +from tlo.methods.fullmodel import fullmodel +from tlo.scenario import BaseScenario + +# capture warnings during simulation run +warnings.simplefilter('default', (UserWarning, RuntimeWarning)) + + +class WastingAnalysis(BaseScenario): + + def __init__(self): + super().__init__( + seed=0, + start_date=Date(year=2010, month=1, day=1), + end_date=Date(year=2031, month=1, day=1), + initial_population_size=20_000, + number_of_draws=1, + runs_per_draw=10, + ) + + def log_configuration(self): + return { + 'filename': 'wasting_analysis__full_model', + 'directory': './outputs/wasting_analysis', + "custom_levels": { # Customise the output of specific loggers + "tlo.methods.demography": logging.INFO, + "tlo.methods.population": logging.INFO, + "tlo.methods.wasting": logging.INFO, + '*': logging.WARNING + } + } + + def modules(self): + return fullmodel( + resourcefilepath=self.resources + ) + + def draw_parameters(self, draw_number, rng): + # Using default parameters in all cases + return {} + + +if __name__ == '__main__': + from tlo.cli import scenario_run + + scenario_run([__file__]) diff --git a/src/scripts/wasting_analyses/scenarios/scenario_wasting_minimal_model.py b/src/scripts/wasting_analyses/scenarios/scenario_wasting_minimal_model.py new file mode 100644 index 0000000000..0830b4b73c --- /dev/null +++ b/src/scripts/wasting_analyses/scenarios/scenario_wasting_minimal_model.py @@ -0,0 +1,89 @@ +""" +This file defines a scenario for wasting analysis. + +It can be submitted on Azure Batch by running: + + tlo batch-submit src/scripts/wasting_analyses/scenarios/scenario_wasting_minimal_model.py + +or locally using: + + tlo scenario-run src/scripts/wasting_analyses/scenarios/scenario_wasting_minimal_model.py +""" +import warnings + +from tlo import Date, logging +from tlo.methods import ( + alri, + demography, + diarrhoea, + enhanced_lifestyle, + epi, + healthburden, + healthseekingbehaviour, + healthsystem, + hiv, + simplified_births, + stunting, + symptommanager, + tb, + wasting, +) +from tlo.scenario import BaseScenario + +# capture warnings during simulation run +warnings.simplefilter('default', (UserWarning, RuntimeWarning)) + + +class WastingAnalysis(BaseScenario): + + def __init__(self): + super().__init__( + seed=0, + start_date=Date(year=2010, month=1, day=1), + end_date=Date(year=2031, month=1, day=1), + initial_population_size=30_000, + number_of_draws=1, + runs_per_draw=1, + ) + + def log_configuration(self): + return { + 'filename': 'wasting_analysis__minimal_model', + 'directory': './outputs/wasting_analysis', + "custom_levels": { # Customise the output of specific loggers + "tlo.methods.demography": logging.INFO, + "tlo.methods.population": logging.INFO, + "tlo.methods.wasting": logging.INFO, + '*': logging.WARNING + } + } + + def modules(self): + return [demography.Demography(resourcefilepath=self.resources), + healthsystem.HealthSystem(resourcefilepath=self.resources, + service_availability=['*'], use_funded_or_actual_staffing='actual', + mode_appt_constraints=1, + cons_availability='default', beds_availability='default', + equip_availability='all'), + healthseekingbehaviour.HealthSeekingBehaviour(resourcefilepath=self.resources), + healthburden.HealthBurden(resourcefilepath=self.resources), + symptommanager.SymptomManager(resourcefilepath=self.resources, spurious_symptoms=True), + enhanced_lifestyle.Lifestyle(resourcefilepath=self.resources), + simplified_births.SimplifiedBirths(resourcefilepath=self.resources), + hiv.Hiv(resourcefilepath=self.resources), + tb.Tb(resourcefilepath=self.resources), + epi.Epi(resourcefilepath=self.resources), + alri.Alri(resourcefilepath=self.resources), + diarrhoea.Diarrhoea(resourcefilepath=self.resources), + stunting.Stunting(resourcefilepath=self.resources), + wasting.Wasting(resourcefilepath=self.resources)] + + def draw_parameters(self, draw_number, rng): + # Using default parameters in all cases + return {} + + +if __name__ == '__main__': + from tlo.cli import scenario_run + + scenario_run([__file__]) diff --git a/src/scripts/wasting_analyses/scenarios/scenario_wasting_orig.py b/src/scripts/wasting_analyses/scenarios/scenario_wasting_orig.py new file mode 100644 index 0000000000..59f7470afd --- /dev/null +++ b/src/scripts/wasting_analyses/scenarios/scenario_wasting_orig.py @@ -0,0 +1,96 @@ +""" +This file defines a scenario for wasting analysis. + +It can be submitted on Azure Batch by running: + + tlo batch-submit src/scripts/wasting_analyses/scenarios/scenario_wasting_orig.py + +or locally using: + + tlo scenario-run src/scripts/wasting_analyses/scenarios/scenario_wasting_orig.py +""" +import warnings + +from tlo import Date, logging +from tlo.methods import ( + alri, + care_of_women_during_pregnancy, + contraception, + demography, + diarrhoea, + enhanced_lifestyle, + epi, + healthburden, + healthseekingbehaviour, + healthsystem, + hiv, + labour, + newborn_outcomes, + postnatal_supervisor, + pregnancy_supervisor, + stunting, + symptommanager, + tb, + wasting, +) +from tlo.scenario import BaseScenario + +# capture warnings during simulation run +warnings.simplefilter('default', (UserWarning, RuntimeWarning)) + + +class WastingAnalysis(BaseScenario): + + def __init__(self): + super().__init__( + seed=0, + start_date=Date(year=2010, month=1, day=1), + end_date=Date(year=2031, month=1, day=1), + initial_population_size=20_000, + number_of_draws=1, + runs_per_draw=1, + ) + + def log_configuration(self): + return { + 'filename': 'wasting_analysis__orig', + 'directory': './outputs', + "custom_levels": { # Customise the output of specific loggers + "tlo.methods.demography": logging.INFO, + "tlo.methods.population": logging.INFO, + "tlo.methods.wasting": logging.INFO, + '*': logging.WARNING + } + } + + def modules(self): + return [demography.Demography(resourcefilepath=self.resources), + healthsystem.HealthSystem(resourcefilepath=self.resources, + service_availability=['*'], cons_availability='default'), + healthseekingbehaviour.HealthSeekingBehaviour(resourcefilepath=self.resources), + healthburden.HealthBurden(resourcefilepath=self.resources), + symptommanager.SymptomManager(resourcefilepath=self.resources), + enhanced_lifestyle.Lifestyle(resourcefilepath=self.resources), + labour.Labour(resourcefilepath=self.resources), + care_of_women_during_pregnancy.CareOfWomenDuringPregnancy(resourcefilepath=self.resources), + contraception.Contraception(resourcefilepath=self.resources), + pregnancy_supervisor.PregnancySupervisor(resourcefilepath=self.resources), + postnatal_supervisor.PostnatalSupervisor(resourcefilepath=self.resources), + newborn_outcomes.NewbornOutcomes(resourcefilepath=self.resources), + hiv.Hiv(resourcefilepath=self.resources), + tb.Tb(resourcefilepath=self.resources), + epi.Epi(resourcefilepath=self.resources), + alri.Alri(resourcefilepath=self.resources), + diarrhoea.Diarrhoea(resourcefilepath=self.resources), + stunting.Stunting(resourcefilepath=self.resources), + wasting.Wasting(resourcefilepath=self.resources)] + + def draw_parameters(self, draw_number, rng): + # Using default parameters in all cases + return {} + + +if __name__ == '__main__': + from tlo.cli import scenario_run + + scenario_run([__file__]) diff --git a/src/tlo/analysis/utils.py b/src/tlo/analysis/utils.py index 7895185d78..e5396b3423 100644 --- a/src/tlo/analysis/utils.py +++ b/src/tlo/analysis/utils.py @@ -838,7 +838,6 @@ def get_color_coarse_appt(coarse_appt_type: str) -> str: '*': 'black', 'FirstAttendance*': 'darkgrey', - 'Inpatient*': 'silver', 'Contraception*': 'darkseagreen', 'AntenatalCare*': 'green', @@ -909,6 +908,7 @@ def get_color_short_treatment_id(short_treatment_id: str) -> str: 'Lower respiratory infections': 'darkorange', 'Childhood Diarrhoea': 'tan', + 'Childhood Undernutrition': 'tomato', 'AIDS': 'deepskyblue', 'Malaria': 'lightsteelblue', diff --git a/src/tlo/methods/equipment.py b/src/tlo/methods/equipment.py index 62776fb3ad..be32006813 100644 --- a/src/tlo/methods/equipment.py +++ b/src/tlo/methods/equipment.py @@ -27,7 +27,7 @@ class Equipment: running even if equipment is declared is not available. For this reason, the ``HSI_Event`` should declare equipment that is *essential* for the healthcare service in its ``__init__`` method. If the logic inside the ``apply`` method of the ``HSI_Event`` depends on the availability of equipment, then it can find the probability with which - item(s) will be available using :py:meth:`.HSI_Event.probability_equipment_available`. + item(s) will be available using :py:meth:`.HSI_Event.probability_all_equipment_available`. The data on the availability of equipment data refers to the proportion of facilities in a district of a particular level (i.e., the ``Facility_ID``) that do have that piece of equipment. In the model, we do not know diff --git a/src/tlo/methods/wasting.py b/src/tlo/methods/wasting.py index c8d3517ad5..113744c38d 100644 --- a/src/tlo/methods/wasting.py +++ b/src/tlo/methods/wasting.py @@ -1,41 +1,1772 @@ -"""Placeholder for childhood wasting module.""" +"""Childhood wasting module""" +from __future__ import annotations -from tlo import Module, Property, Types, logging +import copy +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Union + +import numpy as np +import pandas as pd +from scipy.stats import norm + +from tlo import DateOffset, Module, Parameter, Property, Types, logging +from tlo.events import Event, IndividualScopeEventMixin, PopulationScopeEventMixin, RegularEvent +from tlo.lm import LinearModel, LinearModelType, Predictor +from tlo.methods import Metadata +from tlo.methods.causes import Cause +from tlo.methods.healthsystem import HSI_Event +from tlo.methods.hsi_generic_first_appts import GenericFirstAppointmentsMixin +from tlo.methods.symptommanager import Symptom + +if TYPE_CHECKING: + from tlo.methods.hsi_generic_first_appts import HSIEventScheduler + from tlo.population import IndividualProperties logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -class Wasting(Module): - """Placeholder for childhood wasting module. +# --------------------------------------------------------------------------- +# MODULE DEFINITIONS +# --------------------------------------------------------------------------- +class Wasting(Module, GenericFirstAppointmentsMixin): + """ + This module applies the prevalence of wasting at the population-level, based on the Malawi DHS Survey 2015-2016. + The definitions: + - moderate wasting: weight_for_height Z-score (WHZ) < -2 SD from the reference mean + - severe wasting: weight_for_height Z-score (WHZ) < -3 SD from the reference mean - Provides dummy values for properties required by other modules. """ - INIT_DEPENDENCIES = {'Demography'} + INIT_DEPENDENCIES = {'Demography', 'SymptomManager', 'NewbornOutcomes', 'HealthBurden'} + + METADATA = { + Metadata.DISEASE_MODULE, + Metadata.USES_SYMPTOMMANAGER, + Metadata.USES_HEALTHSYSTEM, + Metadata.USES_HEALTHBURDEN + } + + # Declare Causes of Death + CAUSES_OF_DEATH = { + 'Severe Acute Malnutrition': Cause(gbd_causes='Protein-energy malnutrition', + label='Childhood Undernutrition') + } + + # Declare Causes of Death and Disability + CAUSES_OF_DISABILITY = { + 'Severe Acute Malnutrition': Cause(gbd_causes='Protein-energy malnutrition', + label='Childhood Undernutrition') + } + + PARAMETERS = { + # prevalence of wasting by age group + 'prev_WHZ_distribution_age_0_5mo': Parameter( + Types.LIST, 'distribution of WHZ among less than 6 months of age in 2015'), + 'prev_WHZ_distribution_age_6_11mo': Parameter( + Types.LIST, 'distribution of WHZ among 6 months and 1 year of age in 2015'), + 'prev_WHZ_distribution_age_12_23mo': Parameter( + Types.LIST, 'distribution of WHZ among 1 year olds in 2015'), + 'prev_WHZ_distribution_age_24_35mo': Parameter( + Types.LIST, 'distribution of WHZ among 2 year olds in 2015'), + 'prev_WHZ_distribution_age_36_47mo': Parameter( + Types.LIST, 'distribution of WHZ among 3 year olds in 2015'), + 'prev_WHZ_distribution_age_48_59mo': Parameter( + Types.LIST, 'distribution of WHZ among 4 year olds in 2015'), + # effect of risk factors on wasting prevalence + 'or_wasting_hhwealth_Q5': Parameter( + Types.REAL, 'odds ratio of wasting if household wealth is poorest Q5, ref group Q1'), + 'or_wasting_hhwealth_Q4': Parameter( + Types.REAL, 'odds ratio of wasting if household wealth is poorer Q4, ref group Q1'), + 'or_wasting_hhwealth_Q3': Parameter( + Types.REAL, 'odds ratio of wasting if household wealth is middle Q3, ref group Q1'), + 'or_wasting_hhwealth_Q2': Parameter( + Types.REAL, 'odds ratio of wasting if household wealth is richer Q2, ref group Q1'), + 'or_wasting_preterm_and_AGA': Parameter( + Types.REAL, 'odds ratio of wasting if born preterm and adequate for gestational age'), + 'or_wasting_SGA_and_term': Parameter( + Types.REAL, 'odds ratio of wasting if born term and small for gestational age'), + 'or_wasting_SGA_and_preterm': Parameter( + Types.REAL, 'odds ratio of wasting if born preterm and small for gestational age'), + # incidence + 'base_inc_rate_wasting_by_agegp': Parameter( + Types.LIST, 'List with baseline incidence rate of moderate wasting by age group'), + 'rr_wasting_preterm_and_AGA': Parameter( + Types.REAL, 'relative risk of wasting if born preterm and adequate for gestational age'), + 'rr_wasting_SGA_and_term': Parameter( + Types.REAL, 'relative risk of wasting if born term and small for gestational age'), + 'rr_wasting_SGA_and_preterm': Parameter( + Types.REAL, 'relative risk of wasting if born preterm and small for gestational age'), + 'rr_wasting_wealth_level': Parameter( + Types.REAL, 'relative risk of wasting per 1 unit decrease in wealth level'), + # progression + 'min_days_duration_of_wasting': Parameter( + Types.REAL, 'minimum duration in days of wasting (MAM and SAM)'), + 'duration_of_untreated_mod_wasting': Parameter( + Types.REAL, 'duration of untreated moderate wasting (days)'), + 'duration_of_untreated_sev_wasting': Parameter( + Types.REAL, 'duration of untreated severe wasting (days)'), + 'progression_severe_wasting_by_agegp': Parameter( + Types.LIST, 'List with progression rates to severe wasting by age group'), + 'prob_complications_in_SAM': Parameter( + Types.REAL, 'probability of medical complications in SAM '), + 'duration_sam_to_death': Parameter( + Types.REAL, 'duration of SAM till death if supposed to die due to SAM (days)'), + 'death_rate_untreated_SAM_by_agegp': Parameter( + Types.LIST, 'List with death rates due to untreated SAM by age group'), + # MUAC distributions + 'proportion_WHZ<-3_with_MUAC<115mm': Parameter( + Types.REAL, 'proportion of individuals with severe wasting who have MUAC < 115 mm'), + 'proportion_-3<=WHZ<-2_with_MUAC<115mm': Parameter( + Types.REAL, 'proportion of individuals with moderate wasting who have MUAC < 115 mm'), + 'proportion_-3<=WHZ<-2_with_MUAC_[115-125)mm': Parameter( + Types.REAL, 'proportion of individuals with moderate wasting who have 115 mm ≤ MUAC < 125 mm'), + 'proportion_mam_with_MUAC_[115-125)mm_and_normal_whz': Parameter( + Types.REAL, 'proportion of individuals with MAM who have 115 mm ≤ MUAC < 125 mm and normal/mild' + ' WHZ'), + 'proportion_mam_with_MUAC_[115-125)mm_and_-3<=WHZ<-2': Parameter( + Types.REAL, 'proportion of individuals with MAM who have both 115 mm ≤ MUAC < 125 mm and moderate' + ' wasting'), + 'proportion_mam_with_-3<=WHZ<-2_and_normal_MUAC': Parameter( + Types.REAL, 'proportion of individuals with MAM who have moderate wasting and normal MUAC'), + 'MUAC_distribution_WHZ<-3': Parameter( + Types.LIST, + 'mean and standard deviation of a normal distribution of MUAC measurements for WHZ < -3'), + 'MUAC_distribution_-3<=WHZ<-2': Parameter( + Types.LIST, + 'mean and standard deviation of a normal distribution of MUAC measurements for -3 <= WHZ < -2'), + 'MUAC_distribution_WHZ>=-2': Parameter( + Types.LIST, + 'mean and standard deviation of a normal distribution of MUAC measurements for WHZ >= -2'), + # nutritional oedema + 'prevalence_nutritional_oedema': Parameter( + Types.REAL, 'prevalence of nutritional oedema in children under 5 in Malawi'), + 'proportion_WHZ<-2_with_oedema': Parameter( + Types.REAL, 'proportion of individuals with wasting (moderate or severe) who have oedema'), + 'proportion_oedema_with_WHZ<-2': Parameter( + Types.REAL, 'proportion of individuals with oedema who are wasted (moderately or severely)'), + # detection + 'growth_monitoring_frequency_days': Parameter( + Types.LIST, 'growth monitoring frequency (days), for children [1–2, 2–5] years old'), + 'growth_monitoring_attendance_prob': Parameter( + Types.LIST, 'probability to attend the growth monitoring, for children [1–2, 2–5] years old'), + # recovery due to treatment/interventions + 'recovery_rate_with_soy_RUSF': Parameter( + Types.REAL, 'probability of recovery from wasting following treatment with soy RUSF'), + 'recovery_rate_with_CSB++': Parameter( + Types.REAL, 'probability of recovery from wasting following treatment with CSB++'), + 'recovery_rate_with_standard_RUTF': Parameter( + Types.REAL, 'probability of recovery from wasting following treatment with standard RUTF'), + 'recovery_rate_with_inpatient_care': Parameter( + Types.REAL, 'probability of recovery from wasting following treatment with inpatient care'), + 'tx_length_weeks_SuppFeedingMAM': Parameter( + Types.REAL, 'number of weeks the patient receives treatment in the Supplementary Feeding ' + 'Programme for MAM before being discharged'), + 'tx_length_weeks_OutpatientSAM': Parameter( + Types.REAL, 'number of weeks the patient receives treatment in the Outpatient Therapeutic ' + 'Programme for SAM before being discharged if they do not die beforehand'), + 'tx_length_weeks_InpatientSAM': Parameter( + Types.REAL, 'number of weeks the patient receives treatment in the Inpatient Care for complicated' + ' SAM before being discharged if they do not die beforehand'), + # treatment/intervention outcomes + 'prob_mam_after_SAMcare': Parameter( + Types.REAL, 'probability of returning to MAM from SAM after receiving care'), + 'prob_death_after_SAMcare': Parameter( + Types.REAL, 'probability of dying from SAM after receiving care'), + } PROPERTIES = { - 'un_clinical_acute_malnutrition': Property(Types.CATEGORICAL, - 'temporary property', categories=['MAM', 'SAM', 'well']), - 'un_ever_wasted': Property(Types.BOOL, 'temporary property') + # Properties related to wasting + 'un_ever_wasted': Property(Types.BOOL, 'ever had an episode of wasting (WHZ < -2)'), + 'un_WHZ_category': Property(Types.CATEGORICAL, 'weight-for-height Z-score category', + categories=['WHZ<-3', '-3<=WHZ<-2', 'WHZ>=-2']), + 'un_last_wasting_date_of_onset': Property(Types.DATE, 'date of onset of last episode of wasting'), + + # Properties related to clinical acute malnutrition + 'un_clinical_acute_malnutrition': Property(Types.CATEGORICAL, 'clinical acute malnutrition state ' + 'based on WHZ and/or MUAC and/or nutritional ' + 'oedema', + categories=['MAM', 'SAM', 'well']), + 'un_am_nutritional_oedema': Property(Types.BOOL, 'bilateral pitting oedema present in wasting ' + 'episode'), + 'un_am_MUAC_category': Property(Types.CATEGORICAL, 'MUAC measurement categories, based on WHO ' + 'cut-offs', + categories=['<115mm', '[115-125)mm', '>=125mm']), + 'un_sam_with_complications': Property(Types.BOOL, 'medical complications in SAM episode'), + 'un_sam_death_date': Property(Types.DATE, 'death date from severe acute malnutrition'), + 'un_am_recovery_date': Property(Types.DATE, 'recovery date from last acute malnutrition episode ' + '(MAM/SAM)'), + 'un_am_discharge_date': Property(Types.DATE, 'planned discharge date from last treatment of MAM/SAM ' + 'when recovery will happen if not yet recovered'), + 'un_am_tx_start_date': Property(Types.DATE, 'treatment start date, if currently on treatment'), + 'un_am_treatment_type': Property(Types.CATEGORICAL, 'treatment type for acute malnutrition the person' + ' is currently on; set to not_applicable if well hence no treatment required', + categories=['standard_RUTF', 'soy_RUSF', 'CSB++', 'inpatient_care'] + [ + 'none', 'not_applicable']), } def __init__(self, name=None, resourcefilepath=None): - super().__init__(name=name) + super().__init__(name) + self.prob_normal_whz = None + self.wasting_models = None self.resourcefilepath = resourcefilepath + # wasting states + self.wasting_states = self.PROPERTIES["un_WHZ_category"].categories + # wasting symptom + self.wasting_symptom = 'weight_loss' + + # dict to hold counters for the number of episodes by wasting-type and age-group + blank_inc_counter = dict( + zip(self.wasting_states, [list() for _ in self.wasting_states])) + self.wasting_incident_case_tracker_blank = { + _agrp: copy.deepcopy(blank_inc_counter) for _agrp in ['0y', '1y', '2y', '3y', '4y', '5+y']} + self.wasting_incident_case_tracker = copy.deepcopy(self.wasting_incident_case_tracker_blank) + + self.recovery_options = ['mod_nat_recov', + 'mod_MAM_tx_full_recov', + 'mod_SAM_tx_full_recov', 'mod_SAM_tx_recov_to_MAM', + 'mod_not_yet_recovered', + 'sev_SAM_tx_full_recov', 'sev_SAM_tx_recov_to_MAM', + 'sev_not_yet_recovered'] + blank_length_counter = dict( + zip(self.recovery_options, [list() for _ in self.recovery_options])) + self.wasting_length_tracker_blank = { + _agrp: copy.deepcopy(blank_length_counter) for _agrp in ['0y', '1y', '2y', '3y', '4y', '5+y']} + self.wasting_length_tracker = copy.deepcopy(self.wasting_length_tracker_blank) + + self.age_grps = {0: '0y', 1: '1y', 2: '2y', 3: '3y', 4: '4y'} def read_parameters(self, data_folder): - pass + """ + :param data_folder: path of a folder supplied to the Simulation containing data files. Typically, + modules would read a particular file within here. + """ + # Read parameters from the resource file + self.load_parameters_from_dataframe( + pd.read_csv(Path(self.resourcefilepath) / 'ResourceFile_Wasting.csv') + ) + + # Register wasting symptom (weight loss) in Symptoms Manager with high odds of seeking care + self.sim.modules["SymptomManager"].register_symptom( + Symptom( + name=self.wasting_symptom, + odds_ratio_health_seeking_in_children=20.0, + ) + ) def initialise_population(self, population): + """ + Set our property values for the initial population. This method is called by the simulation when creating + the initial population, and is responsible for assigning initial values, for every individual, + of those properties 'owned' by this module, i.e. those declared in the PROPERTIES dictionary above. + + :param population: + """ df = population.props - df.loc[df.is_alive, 'un_clinical_acute_malnutrition'] = 'well' + + # Set initial properties df.loc[df.is_alive, 'un_ever_wasted'] = False + df.loc[df.is_alive, 'un_WHZ_category'] = 'WHZ>=-2' # not undernourished + # df.loc[df.is_alive, 'un_last_wasting_date_of_onset'] = pd.NaT + df.loc[df.is_alive, 'un_clinical_acute_malnutrition'] = 'well' + df.loc[df.is_alive, 'un_am_nutritional_oedema'] = False + df.loc[df.is_alive, 'un_am_MUAC_category'] = '>=125mm' + # df.loc[df.is_alive, 'un_sam_death_date'] = pd.NaT + # df.loc[df.is_alive, 'un_am_recovery_date'] = pd.NaT + # df.loc[df.is_alive, 'un_am_discharge_date'] = pd.NaT + # df.loc[df.is_alive, 'un_am_tx_start_date'] = pd.NaT + df.loc[df.is_alive, 'un_am_treatment_type'] = 'not_applicable' + + # initialise wasting linear models. + self.wasting_models = WastingModels(self) + + # Assign wasting categories in young children at initiation + for low_bound_mos, high_bound_mos in [(0, 5), (6, 11), (12, 23), (24, 35), (36, 47), (48, 59)]: # in months + low_bound_age_in_years = low_bound_mos / 12.0 + high_bound_age_in_years = (1 + high_bound_mos) / 12.0 + # linear model external variables + agegp = f'{low_bound_mos}_{high_bound_mos}mo' + children_of_agegp = df.loc[df.is_alive & df.age_exact_years.between( + low_bound_age_in_years, high_bound_age_in_years, inclusive='left' + )] + + # apply prevalence of wasting and categorise into moderate (-3 <= WHZ < -2) or severe (WHZ < -3) wasting + wasted = self.wasting_models.get_wasting_prevalence(agegp=agegp).predict( + children_of_agegp, self.rng, False + ) + probability_of_severe = self.get_prob_severe_wasting_among_wasted(agegp=agegp) + for idx in children_of_agegp.index[wasted]: + wasted_category = self.rng.choice(['WHZ<-3', '-3<=WHZ<-2'], p=[probability_of_severe, + 1 - probability_of_severe]) + df.at[idx, 'un_WHZ_category'] = wasted_category + df.at[idx, 'un_last_wasting_date_of_onset'] = self.sim.date + df.at[idx, 'un_ever_wasted'] = True + + index_under5 = df.index[df.is_alive & (df.age_exact_years < 5)] + # calculate approximation of probability of having normal WHZ in children under 5 to be used later + self.prob_normal_whz = \ + len(index_under5.intersection(df.index[df.un_WHZ_category == 'WHZ>=-2'])) / len(index_under5) + # ----------------------------------------------------------------------------------------------------- # + # # # # Give MUAC category, presence of oedema, and determine acute malnutrition state # # # # + # # # # and, in SAM cases, determine presence of complications and eventually schedule death # # # # + self.clinical_signs_acute_malnutrition(index_under5) def initialise_simulation(self, sim): + """Prepares for simulation. Schedules: + * the first growth monitoring to happen straight away, scheduled monthly to detect new cases for treatment. + * the main incidence polling event. + * the main logging event. + """ + + sim.schedule_event(Wasting_InitLoggingEvent(self), sim.date) + sim.schedule_event(Wasting_InitiateGrowthMonitoring(self), sim.date) + sim.schedule_event(Wasting_IncidencePoll(self), sim.date + DateOffset(months=3)) + sim.schedule_event(Wasting_LoggingEvent(self), sim.date + DateOffset(years=1) - DateOffset(days=1)) + + def on_birth(self, mother_id, child_id): + """Initialise properties for a newborn individual. + :param mother_id: the mother for this child + :param child_id: the new child + """ + + df = self.sim.population.props + + # Set initial properties + df.at[child_id, 'un_ever_wasted'] = False + df.at[child_id, 'un_WHZ_category'] = 'WHZ>=-2' # not undernourished + # df.at[child_id, 'un_last_wasting_date_of_onset'] = pd.NaT + df.at[child_id, 'un_clinical_acute_malnutrition'] = 'well' + df.at[child_id, 'un_am_nutritional_oedema'] = False + df.at[child_id, 'un_am_MUAC_category'] = '>=125mm' + # df.loc[df.is_alive, 'un_sam_death_date'] = pd.NaT + # df.loc[df.is_alive, 'un_am_recovery_date'] = pd.NaT + # df.loc[df.is_alive, 'un_am_discharge_date'] = pd.NaT + # df.loc[df.is_alive, 'un_am_tx_start_date'] = pd.NaT + df.at[child_id, 'un_am_treatment_type'] = 'not_applicable' + + def get_prob_severe_wasting_among_wasted(self, agegp: str) -> Union[float, int]: + """ + This function will calculate the WHZ scores by categories and return probability of severe wasting + for those with wasting status + :param agegp: age grouped in months + :return: probability of severe wasting among all wasting cases + """ + # generate random numbers from N(mean, sd) + mean, stdev = self.parameters[f'prev_WHZ_distribution_age_{agegp}'] + whz_normal_distribution = norm(loc=mean, scale=stdev) + + # get probability of any wasting: WHZ < -2 + probability_less_than_minus2sd = 1 - whz_normal_distribution.sf(-2) + + # get probability of severe wasting: WHZ < -3 + probability_less_than_minus3sd = 1 - whz_normal_distribution.sf(-3) + + # make WHZ < -2 as the 100% and get the adjusted probability of severe wasting within overall wasting + # return the probability of severe wasting among all wasting cases + return probability_less_than_minus3sd / probability_less_than_minus2sd + + def get_odds_wasting(self, agegp: str) -> Union[float, int]: + """ + This function will calculate the WHZ scores by categories and return odds of wasting + :param agegp: age grouped in months + :return: odds of wasting among all children under 5 + """ + # generate random numbers from N(mean, sd) + mean, stdev = self.parameters[f'prev_WHZ_distribution_age_{agegp}'] + whz_normal_distribution = norm(loc=mean, scale=stdev) + + # get probability of any wasting: WHZ < -2 + probability_less_than_minus2sd = 1 - whz_normal_distribution.sf(-2) + + # convert probability of wasting to odds and return the odds of wasting + return probability_less_than_minus2sd / (1 - probability_less_than_minus2sd) + + def muac_cutoff_by_WHZ(self, idx, whz): + """ + Proportion of MUAC < 115 mm in WHZ < -3 and -3 <= WHZ < -2, + and proportion of wasted children with oedematous malnutrition (kwashiorkor, marasmic kwashiorkor) + + :param idx: index of children ages 6-59 months or person_id + :param whz: weight for height category + """ + df = self.sim.population.props + p = self.parameters + + # ----- MUAC distribution for severe wasting (WHZ < -3) ------ + if whz == 'WHZ<-3': + # for severe wasting assumed no MUAC >= 125mm + prop_severe_wasting_with_muac_between_115and125mm = 1 - p['proportion_WHZ<-3_with_MUAC<115mm'] + + df.loc[idx, 'un_am_MUAC_category'] = df.loc[idx].apply( + lambda x: self.rng.choice(['<115mm', '[115-125)mm'], + p=[p['proportion_WHZ<-3_with_MUAC<115mm'], + prop_severe_wasting_with_muac_between_115and125mm]), + axis=1 + ) + + # ----- MUAC distribution for moderate wasting (-3 <= WHZ < -2) ------ + if whz == '-3<=WHZ<-2': + prop_moderate_wasting_with_muac_over_125mm = \ + 1 - p['proportion_-3<=WHZ<-2_with_MUAC<115mm'] - p['proportion_-3<=WHZ<-2_with_MUAC_[115-125)mm'] + + df.loc[idx, 'un_am_MUAC_category'] = df.loc[idx].apply( + lambda x: self.rng.choice(['<115mm', '[115-125)mm', '>=125mm'], + p=[p['proportion_-3<=WHZ<-2_with_MUAC<115mm'], + p['proportion_-3<=WHZ<-2_with_MUAC_[115-125)mm'], + prop_moderate_wasting_with_muac_over_125mm]), + axis=1 + ) + + # ----- MUAC distribution for WHZ >= -2 ----- + if whz == 'WHZ>=-2': + + muac_distribution_in_well_group = norm(loc=p['MUAC_distribution_WHZ>=-2'][0], + scale=p['MUAC_distribution_WHZ>=-2'][1]) + # get probabilities of MUAC + prob_normal_whz_with_muac_over_115mm = muac_distribution_in_well_group.sf(11.5) + prob_normal_whz_with_muac_over_125mm = muac_distribution_in_well_group.sf(12.5) + + prob_normal_whz_with_muac_less_than_115mm = 1 - prob_normal_whz_with_muac_over_115mm + prob_normal_whz_with_muac_between_115and125mm = \ + prob_normal_whz_with_muac_over_115mm - prob_normal_whz_with_muac_over_125mm + + df.loc[idx, 'un_am_MUAC_category'] = df.loc[idx].apply( + lambda x: self.rng.choice(['<115mm', '[115-125)mm', '>=125mm'], + p=[prob_normal_whz_with_muac_less_than_115mm, + prob_normal_whz_with_muac_between_115and125mm, + prob_normal_whz_with_muac_over_125mm]), + axis=1 + ) + + def nutritional_oedema_present(self, idx): + """ + This function applies the probability of nutritional oedema present in wasting and non-wasted cases + :param idx: index of children under 5, or person_id + """ + if len(idx) == 0: + return + df = self.sim.population.props + p = self.parameters + + # Knowing the prevalence of nutritional oedema in under 5 population, + # apply the probability of oedema in WHZ < -2 + # get those children with wasting + children_with_wasting = idx.intersection(df.index[df.un_WHZ_category != 'WHZ>=-2']) + children_without_wasting = idx.intersection(df.index[df.un_WHZ_category == 'WHZ>=-2']) + + # oedema among wasted children + oedema_in_wasted_children = self.rng.random_sample(size=len( + children_with_wasting)) < p['proportion_WHZ<-2_with_oedema'] + df.loc[children_with_wasting, 'un_am_nutritional_oedema'] = oedema_in_wasted_children + + # oedema among non-wasted children + if len(children_without_wasting) == 0: + return + # proportion_normalWHZ_with_oedema: P(oedema|WHZ>=-2) = + # P(oedema & WHZ>=-2) / P(WHZ>=-2) = P(oedema) * [1 - P(WHZ<-2|oedema)] / P(WHZ>=-2) + proportion_normal_whz_with_oedema = \ + p['prevalence_nutritional_oedema'] * (1 - p['proportion_oedema_with_WHZ<-2']) / self.prob_normal_whz + oedema_in_non_wasted = self.rng.random_sample(size=len( + children_without_wasting)) < proportion_normal_whz_with_oedema + df.loc[children_without_wasting, 'un_am_nutritional_oedema'] = oedema_in_non_wasted + + def clinical_acute_malnutrition_state(self, person_id, pop_dataframe): + """ + This function will determine the clinical acute malnutrition status (MAM, SAM) based on anthropometric indices + and presence of nutritional oedema (Kwashiorkor); And help determine whether the individual will have medical + complications, applicable to SAM cases only, requiring inpatient care. + :param person_id: individual id + :param pop_dataframe: population dataframe + """ + df = pop_dataframe + p = self.parameters + + whz = df.at[person_id, 'un_WHZ_category'] + muac = df.at[person_id, 'un_am_MUAC_category'] + oedema_presence = df.at[person_id, 'un_am_nutritional_oedema'] + + # if person well + if (whz == 'WHZ>=-2') and (muac == '>=125mm') and (not oedema_presence): + df.at[person_id, 'un_clinical_acute_malnutrition'] = 'well' + # if person not well + else: + # start without treatment + df.at[person_id, 'un_am_treatment_type'] = 'none' + # reset recovery date + df.at[person_id, 'un_am_recovery_date'] = pd.NaT + + # severe acute malnutrition (SAM): MUAC < 115 mm and/or WHZ < -3 and/or nutritional oedema + if (muac == '<115mm') or (whz == 'WHZ<-3') or oedema_presence: + df.at[person_id, 'un_clinical_acute_malnutrition'] = 'SAM' + # apply symptoms to all SAM cases + self.wasting_clinical_symptoms(person_id=person_id) + + # otherwise moderate acute malnutrition (MAM) + else: + df.at[person_id, 'un_clinical_acute_malnutrition'] = 'MAM' + + if df.at[person_id, 'un_clinical_acute_malnutrition'] == 'SAM': + # Determine if SAM episode has complications + if self.rng.random_sample() < p['prob_complications_in_SAM']: + df.at[person_id, 'un_sam_with_complications'] = True + else: + df.at[person_id, 'un_sam_with_complications'] = False + # Determine whether the SAM leads to death + death_due_to_sam = self.wasting_models.death_due_to_sam_lm.predict( + df.loc[[person_id]], rng=self.rng + ) + if death_due_to_sam: + outcome_date = self.date_of_death_for_untreated_sam() + self.sim.schedule_event( + event=Wasting_SevereAcuteMalnutritionDeath_Event(module=self, person_id=person_id), + date=outcome_date + ) + df.at[person_id, 'un_sam_death_date'] = outcome_date + + else: + df.at[person_id, 'un_sam_with_complications'] = False + # clear all wasting symptoms + self.sim.modules["SymptomManager"].clear_symptoms( + person_id=person_id, disease_module=self + ) + + assert not ((df.at[person_id, 'un_clinical_acute_malnutrition'] == 'MAM') + and (df.at[person_id, 'un_sam_with_complications'])) + + def date_of_outcome_for_untreated_wasting(self, whz_category): + """ + Helper function to determine the duration of the wasting episode to get date of outcome (recovery, progression, + or death). + :param whz_category: 'WHZ<-3', or '-3<=WHZ<-2' + :return: date of outcome, which can be recovery to no wasting, progression to severe wasting, or death due to + SAM in cases of moderate wasting; or recovery to moderate wasting or death due to SAM in cases of severe wasting + """ + p = self.parameters + + # moderate wasting (for progression to severe, or recovery to no wasting) ----- + if whz_category == '-3<=WHZ<-2': + # Allocate the duration of the moderate wasting episode + duration_mod_wasting = int(max(p['min_days_duration_of_wasting'], p['duration_of_untreated_mod_wasting'])) + # Allocate a date of outcome (death, progression, or recovery) + date_of_outcome = self.sim.date + DateOffset(days=duration_mod_wasting) + return date_of_outcome + + # severe wasting (recovery to moderate wasting) ----- + if whz_category == 'WHZ<-3': + # determine the duration of severe wasting episode + duration_sev_wasting = int(max(p['min_days_duration_of_wasting'], p['duration_of_untreated_sev_wasting'])) + # Allocate a date of outcome (death, progression, or recovery) + date_of_outcome = self.sim.date + DateOffset(days=duration_sev_wasting) + return date_of_outcome + + def date_of_death_for_untreated_sam(self): + """ + Helper function to determine date of death, assuming it occurs earlier than the progression/recovery from any + wasting, moderate or severe. + :return: date of death + """ + p = self.parameters + + duration_sam_to_death = int(min(p['duration_of_untreated_mod_wasting'], p['duration_of_untreated_sev_wasting'], + p['duration_sam_to_death'])) + date_of_death = self.sim.date + DateOffset(days=duration_sam_to_death) + return date_of_death + + + def clinical_signs_acute_malnutrition(self, idx): + """ + When WHZ changed, update other anthropometric indices and clinical signs (MUAC, oedema) that determine the + clinical state of acute malnutrition. If SAM, update medical complications. If not SAM, clear symptoms. + This will include both wasted and non-wasted children with other signs of acute malnutrition. + :param idx: index of children or person_id less than 5 years old + """ + df = self.sim.population.props + + # if idx only person_id, transform into an Index object + if not isinstance(idx, pd.Index): + idx = pd.Index([idx]) + + # give MUAC measurement category for all WHZ, including normal WHZ ----- + for whz in ['WHZ<-3', '-3<=WHZ<-2', 'WHZ>=-2']: + index_6_59mo_by_whz = idx.intersection(df.index[df.age_exact_years.between(0.5, 5, inclusive='left') + & (df.un_WHZ_category == whz)]) + self.muac_cutoff_by_WHZ(idx=index_6_59mo_by_whz, whz=whz) + + # determine the presence of nutritional oedema (oedematous malnutrition) + self.nutritional_oedema_present(idx=idx) + + # determine the clinical acute malnutrition state ----- + for person_id in idx: + self.clinical_acute_malnutrition_state(person_id=person_id, pop_dataframe=df) + + def report_daly_values(self): + """ + This must send back a pd.Series or pd.DataFrame that reports on the average daly-weights that have been + experienced by persons in the previous month. Only rows for alive-persons must be returned. The names of the + series of columns is taken to be the label of the cause of this disability. It will be recorded by the + healthburden module as _. + :return: + """ + # Dict to hold the DALY weights + daly_wts = dict() + + df = self.sim.population.props + # Get DALY weights + get_daly_weight = self.sim.modules['HealthBurden'].get_daly_weight + + daly_wts['mod_wasting_with_oedema'] = get_daly_weight(sequlae_code=461) + daly_wts['sev_wasting_w/o_oedema'] = get_daly_weight(sequlae_code=462) + daly_wts['sev_wasting_with_oedema'] = get_daly_weight(sequlae_code=463) + + total_daly_values = pd.Series(data=0.0, + index=df.index[df.is_alive]) + total_daly_values.loc[df.is_alive & (df.un_WHZ_category == 'WHZ<-3') & + df.un_am_nutritional_oedema] = daly_wts['sev_wasting_with_oedema'] + total_daly_values.loc[df.is_alive & (df.un_WHZ_category == 'WHZ<-3') & + (~df.un_am_nutritional_oedema)] = daly_wts['sev_wasting_w/o_oedema'] + total_daly_values.loc[df.is_alive & (df.un_WHZ_category == '-3<=WHZ<-2') & + df.un_am_nutritional_oedema] = daly_wts['mod_wasting_with_oedema'] + return total_daly_values + + def wasting_clinical_symptoms(self, person_id): + """ + assign clinical symptoms to new acute malnutrition cases + :param person_id: + """ + df = self.sim.population.props + if df.at[person_id, 'un_clinical_acute_malnutrition'] != 'SAM': + return + + # apply wasting symptoms to all SAM cases + self.sim.modules["SymptomManager"].change_symptom( + person_id=person_id, + symptom_string=self.wasting_symptom, + add_or_remove="+", + disease_module=self + ) + + def do_at_generic_first_appt( + self, + person_id: int, + individual_properties: IndividualProperties, + schedule_hsi_event: HSIEventScheduler, + **kwargs, + ) -> None: + if (individual_properties["age_years"] >= 5) or \ + (individual_properties["un_am_treatment_type"] in + ['standard_RUTF', 'soy_RUSF', 'CSB++', 'inpatient_care']): + return + + # p = self.parameters + + # get the clinical states + clinical_am = individual_properties['un_clinical_acute_malnutrition'] + complications = individual_properties['un_sam_with_complications'] + + # No interventions if well + if clinical_am == 'well': + return + + # Interventions for MAM + elif clinical_am == 'MAM': + # schedule HSI for supplementary feeding program for MAM + schedule_hsi_event( + hsi_event=HSI_Wasting_SupplementaryFeedingProgramme_MAM(module=self, person_id=person_id), + priority=0, topen=self.sim.date) + + elif clinical_am == 'SAM': + + # Interventions for uncomplicated SAM + if not complications: + # schedule HSI for supplementary feeding program for MAM + schedule_hsi_event( + hsi_event=HSI_Wasting_OutpatientTherapeuticProgramme_SAM(module=self, person_id=person_id), + priority=0, topen=self.sim.date) + + # Interventions for complicated SAM + if complications: + # schedule HSI for supplementary feeding program for MAM + schedule_hsi_event( + hsi_event=HSI_Wasting_InpatientTherapeuticCare_ComplicatedSAM(module=self, person_id=person_id), + priority=0, topen=self.sim.date) + + def do_when_am_treatment(self, person_id, intervention): + """ + This function will apply the linear model of recovery based on intervention given + :param person_id: + :param intervention: + """ + df = self.sim.population.props + p = self.parameters + # Set the date when the treatment is provided: + df.at[person_id, 'un_am_tx_start_date'] = self.sim.date + # Reset tx discharge date + df.at[person_id, 'un_am_discharge_date'] = pd.NaT + # Cancel natural death with due to tx + df.at[person_id, 'un_sam_death_date'] = pd.NaT + + if intervention == 'SFP': + df.at[person_id, 'un_am_discharge_date'] = \ + self.sim.date + DateOffset(weeks=p['tx_length_weeks_SuppFeedingMAM']) + + mam_full_recovery = self.wasting_models.acute_malnutrition_recovery_mam_lm.predict( + df.loc[[person_id]], self.rng + ) + + if mam_full_recovery: + # schedule recovery date + self.sim.schedule_event( + event=Wasting_ClinicalAcuteMalnutritionRecovery_Event(module=self, person_id=person_id), + date=(df.at[person_id, 'un_am_discharge_date']) + ) + # cancel progression date (in ProgressionEvent) + else: + # remained MAM + return + + elif intervention in ['OTP', 'ITC']: + if intervention == 'OTP': + outcome_date = (self.sim.date + DateOffset(weeks=p['tx_length_weeks_OutpatientSAM'])) + else: + outcome_date = (self.sim.date + DateOffset(weeks=p['tx_length_weeks_InpatientSAM'])) + + sam_full_recovery = self.wasting_models.acute_malnutrition_recovery_sam_lm.predict( + df.loc[[person_id]], self.rng + ) + if sam_full_recovery: + df.at[person_id, 'un_am_discharge_date'] = outcome_date + # schedule full recovery + self.sim.schedule_event( + event=Wasting_ClinicalAcuteMalnutritionRecovery_Event(module=self, person_id=person_id), + date=outcome_date + ) + + else: + outcome = self.rng.choice(['recovery_to_mam', 'death'], p=[self.parameters['prob_mam_after_SAMcare'], + self.parameters['prob_death_after_SAMcare']]) + if outcome == 'death': + self.sim.schedule_event( + event=Wasting_SevereAcuteMalnutritionDeath_Event(module=self, person_id=person_id), + date=outcome_date + ) + df.at[person_id, 'un_sam_death_date'] = outcome_date + else: # recovery to MAM and follow-up treatment for MAM + df.at[person_id, 'un_am_discharge_date'] = outcome_date + self.sim.schedule_event(event=Wasting_UpdateToMAM_Event(module=self, person_id=person_id), + date=outcome_date) + self.sim.modules['HealthSystem'].schedule_hsi_event( + hsi_event=HSI_Wasting_SupplementaryFeedingProgramme_MAM(module=self, person_id=person_id), + priority=0, topen=outcome_date) + + +class Wasting_IncidencePoll(RegularEvent, PopulationScopeEventMixin): + """ + Regular event that determines new cases of wasting (WHZ < -2) to the under-5 population, and schedules + individual incident cases to represent onset. It determines those who will progress to severe wasting + (WHZ < -3) and schedules the event to update on properties. These are events occurring without the input + of interventions, these events reflect the natural history only. + """ + AGE_GROUPS = {0: '0y', 1: '1y', 2: '2y', 3: '3y', 4: '4y'} + + def __init__(self, module): + """schedule to run every month + :param module: the module that created this event + """ + super().__init__(module, frequency=DateOffset(months=1)) + assert isinstance(module, Wasting) + + def apply(self, population): + """Apply this event to the population. + :param population: the current population + """ + df = population.props + rng = self.module.rng + + # # # INCIDENCE OF MODERATE WASTING # # # # # # # # # # # # # # # # # # # # # + # Determine who will be onset with wasting among those who are not currently wasted ------------- + not_wasted_or_treated = df.loc[df.is_alive & (df.age_exact_years < 5) & (df.un_WHZ_category == 'WHZ>=-2') & + (df.un_am_tx_start_date != pd.NaT)] + incidence_of_wasting = self.module.wasting_models.wasting_incidence_lm.predict(not_wasted_or_treated, rng=rng) + mod_wasting_new_cases = not_wasted_or_treated.loc[incidence_of_wasting] + mod_wasting_new_cases_idx = mod_wasting_new_cases.index + # update the properties for new cases of wasted children + df.loc[mod_wasting_new_cases_idx, 'un_ever_wasted'] = True + df.loc[mod_wasting_new_cases_idx, 'un_last_wasting_date_of_onset'] = self.sim.date + # initiate moderate wasting + df.loc[mod_wasting_new_cases_idx, 'un_WHZ_category'] = '-3<=WHZ<-2' + # ------------------------------------------------------------------------------------------- + # Add these incident cases to the tracker + for person_id in mod_wasting_new_cases_idx: + age_group = Wasting_IncidencePoll.AGE_GROUPS.get(df.loc[person_id].age_years, '5+y') + self.module.wasting_incident_case_tracker[age_group]['-3<=WHZ<-2'].append(self.sim.date) + # Update properties related to clinical acute malnutrition + # (MUAC, oedema, clinical state of acute malnutrition and if SAM complications and death; + # clear symptoms if not SAM) + self.module.clinical_signs_acute_malnutrition(mod_wasting_new_cases_idx) + # ------------------------------------------------------------------------------------------- + + outcome_date = self.module.date_of_outcome_for_untreated_wasting(whz_category='-3<=WHZ<-2') + + # # # PROGRESS TO SEVERE WASTING # # # # # # # # # # # # # # # # # # + # Determine those that will progress to severe wasting (WHZ < -3) and schedule progression event --------- + progression_severe_wasting = self.module.wasting_models.severe_wasting_progression_lm.predict( + df.loc[mod_wasting_new_cases_idx], rng=rng, squeeze_single_row_output=False + ) + + for person_id in mod_wasting_new_cases_idx[progression_severe_wasting]: + # schedule severe wasting WHZ < -3 onset after duration of moderate wasting + self.sim.schedule_event( + event=Wasting_ProgressionToSevere_Event( + module=self.module, person_id=person_id), date=outcome_date + ) + + # # # MODERATE WASTING NATURAL RECOVERY # # # # # # # # # # # # # # + # Schedule recovery from moderate wasting for those not progressing to severe wasting --------- + for person_id in mod_wasting_new_cases_idx[~progression_severe_wasting]: + # schedule recovery after duration of moderate wasting + self.sim.schedule_event(event=Wasting_NaturalRecovery_Event( + module=self.module, person_id=person_id), date=outcome_date) + + +class Wasting_ProgressionToSevere_Event(Event, IndividualScopeEventMixin): + """ + This Event is for the onset of severe wasting (WHZ < -3). + * Refreshes all the properties so that they pertain to this current episode of wasting + * Imposes wasting symptom + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + + def apply(self, person_id): + df = self.sim.population.props # shortcut to the dataframe + + if ( + (not df.at[person_id, 'is_alive']) or + (df.at[person_id, 'age_exact_years'] >= 5) or + (df.at[person_id, 'un_WHZ_category'] != '-3<=WHZ<-2') or + (df.at[person_id, 'un_last_wasting_date_of_onset'] < df.at[person_id, 'un_am_tx_start_date'] < + self.sim.date) + ): + return + + else: + # # # INCIDENCE OF SEVERE WASTING # # # # # # # # # # # # # # # # # # # # # + # Continue with progression to severe if not treated/recovered + # update properties + # - WHZ + df.at[person_id, 'un_WHZ_category'] = 'WHZ<-3' + # - MUAC, oedema, clinical state of acute malnutrition, complications, death + self.module.clinical_signs_acute_malnutrition(person_id) + + # ------------------------------------------------------------------------------------------- + # Add this severe wasting incident case to the tracker + age_group = Wasting_IncidencePoll.AGE_GROUPS.get(df.loc[person_id].age_years, '5+y') + self.module.wasting_incident_case_tracker[age_group]['WHZ<-3'].append(self.sim.date) + + if pd.isnull(df.at[person_id, 'un_sam_death_date']): + # # # SEVERE WASTING NATURAL RECOVERY # # # # # # # # # # # # # # # # + # Schedule recovery from severe wasting for those not dying due to SAM + outcome_date = self.module.date_of_outcome_for_untreated_wasting(whz_category='WHZ<-3') + self.sim.schedule_event(event=Wasting_NaturalRecovery_Event( + module=self.module, person_id=person_id), date=outcome_date) + + +class Wasting_SevereAcuteMalnutritionDeath_Event(Event, IndividualScopeEventMixin): + """ + This event applies the death function + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + + def apply(self, person_id): + df = self.sim.population.props # shortcut to the dataframe + + # The event should not run if the person is not currently alive or doesn't have SAM + if not df.at[person_id, 'is_alive']: + return + + # # Check if this person should still die from SAM: + if ( + pd.isnull(df.at[person_id, 'un_am_recovery_date']) and + not (df.at[person_id, 'un_am_discharge_date'] > df.at[person_id, 'un_am_tx_start_date']) and + not pd.isnull(df.at[person_id, 'un_sam_death_date']) + ): + assert df.at[person_id, 'un_clinical_acute_malnutrition'] == 'SAM' + # Cause the death to happen immediately + df.at[person_id, 'un_sam_death_date'] = self.sim.date + self.sim.modules['Demography'].do_death( + individual_id=person_id, + cause='Severe Acute Malnutrition', + originating_module=self.module) + else: + df.at[person_id, 'un_sam_death_date'] = pd.NaT + + +class Wasting_NaturalRecovery_Event(Event, IndividualScopeEventMixin): + """ + This event improves wasting by 1 SD, based on home care/improvement without interventions. + MUAC, oedema, clinical state of acute malnutrition, and if SAM complications are updated, + and symptoms cleared if not SAM. + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + + def apply(self, person_id): + df = self.sim.population.props # shortcut to the dataframe + + if ( + (not df.at[person_id, 'is_alive']) or + (df.at[person_id, 'un_WHZ_category'] == 'WHZ>=-2') or + (not pd.isnull(df.at[person_id, 'un_sam_death_date'])) + ): + return + + whz = df.at[person_id, 'un_WHZ_category'] + if whz == '-3<=WHZ<-2': + # improve WHZ + df.at[person_id, 'un_WHZ_category'] = 'WHZ>=-2' # not undernourished + age_group = self.module.age_grps.get(df.loc[person_id].age_years, '5+y') + self.module.wasting_length_tracker[age_group]['mod_nat_recov'].append( + (self.sim.date - df.at[person_id, 'un_last_wasting_date_of_onset']).days + ) + + else: + # whz == 'WHZ<-3' + # improve WHZ + df.at[person_id, 'un_WHZ_category'] = '-3<=WHZ<-2' # moderate wasting + + # update MUAC, oedema, clinical state of acute malnutrition and if SAM complications and death, + # clear symptoms if not SAM + self.module.clinical_signs_acute_malnutrition(person_id) + # set recovery date if recovered + if df.at[person_id, 'un_clinical_acute_malnutrition'] == 'well': + df.at[person_id, 'un_am_recovery_date'] = self.sim.date + df.at[person_id, 'un_sam_death_date'] = pd.NaT + + +class Wasting_ClinicalAcuteMalnutritionRecovery_Event(Event, IndividualScopeEventMixin): + """ + This event sets wasting properties back to normal state. + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + + def apply(self, person_id): + df = self.sim.population.props # shortcut to the dataframe + + if not df.at[person_id, 'is_alive']: + return + + if df.at[person_id, 'un_WHZ_category'] != 'WHZ>=-2': + if df.at[person_id, 'un_WHZ_category'] == '-3<=WHZ<-2': + recov_opt = f"mod_{df.at[person_id, 'un_clinical_acute_malnutrition']}_tx_full_recov" + elif df.at[person_id, 'un_WHZ_category'] == 'WHZ<-3': + recov_opt = f"sev_{df.at[person_id, 'un_clinical_acute_malnutrition']}_tx_full_recov" + age_group = self.module.age_grps.get(df.loc[person_id].age_years, '5+y') + self.module.wasting_length_tracker[age_group][recov_opt].append( + (self.sim.date - df.at[person_id, 'un_last_wasting_date_of_onset']).days + ) + + df.at[person_id, 'un_am_recovery_date'] = self.sim.date + df.at[person_id, 'un_WHZ_category'] = 'WHZ>=-2' # not undernourished + df.at[person_id, 'un_clinical_acute_malnutrition'] = 'well' + df.at[person_id, 'un_am_nutritional_oedema'] = False + df.at[person_id, 'un_am_MUAC_category'] = '>=125mm' + df.at[person_id, 'un_sam_with_complications'] = False + df.at[person_id, 'un_sam_death_date'] = pd.NaT + df.at[person_id, 'un_am_tx_start_date'] = pd.NaT + df.at[person_id, 'un_am_treatment_type'] = 'not_applicable' + + # this will clear all wasting symptoms + self.sim.modules["SymptomManager"].clear_symptoms( + person_id=person_id, disease_module=self.module + ) + + +class Wasting_UpdateToMAM_Event(Event, IndividualScopeEventMixin): + """ + This event updates the properties for those cases that remained/improved from SAM to MAM following + treatment + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + + def apply(self, person_id): + df = self.sim.population.props # shortcut to the dataframe + rng = self.module.rng + p = self.module.parameters + + if (not df.at[person_id, 'is_alive']) or (df.at[person_id, 'un_clinical_acute_malnutrition'] != 'SAM'): + return + + # For cases with normal WHZ and other acute malnutrition signs: + # oedema, or low MUAC - do not change the WHZ + if df.at[person_id, 'un_WHZ_category'] == 'WHZ>=-2': + # MAM by MUAC only + df.at[person_id, 'un_am_MUAC_category'] = '[115-125)mm' + # TODO: I think this changes the proportions below as some of the cases will be issued here + + else: + # using the probability of mam classification by anthropometric indices + mam_classification = rng.choice(['mam_by_muac_only', 'mam_by_muac_and_whz', 'mam_by_whz_only'], + p=[p['proportion_mam_with_MUAC_[115-125)mm_and_normal_whz'], + p['proportion_mam_with_MUAC_[115-125)mm_and_-3<=WHZ<-2'], + p['proportion_mam_with_-3<=WHZ<-2_and_normal_MUAC']]) + + if mam_classification == 'mam_by_muac_only': + if df.at[person_id, 'un_WHZ_category'] == '-3<=WHZ<-2': + recov_opt = "mod_SAM_tx_recov_to_MAM" + elif df.at[person_id, 'un_WHZ_category'] == 'WHZ<-3': + recov_opt = "sev_SAM_tx_recov_to_MAM" + age_group = self.module.age_grps.get(df.loc[person_id].age_years, '5+y') + self.module.wasting_length_tracker[age_group][recov_opt].append( + (self.sim.date - df.at[person_id, 'un_last_wasting_date_of_onset']).days + ) + df.at[person_id, 'un_WHZ_category'] = 'WHZ>=-2' + df.at[person_id, 'un_am_MUAC_category'] = '[115-125)mm' + + if mam_classification == 'mam_by_muac_and_whz': + df.at[person_id, 'un_WHZ_category'] = '-3<=WHZ<-2' + df.at[person_id, 'un_am_MUAC_category'] = '[115-125)mm' + + if mam_classification == 'mam_by_whz_only': + df.at[person_id, 'un_WHZ_category'] = '-3<=WHZ<-2' + df.at[person_id, 'un_am_MUAC_category'] = '>=125mm' + + # Update all other properties equally + df.at[person_id, 'un_clinical_acute_malnutrition'] = 'MAM' + df.at[person_id, 'un_am_nutritional_oedema'] = False + df.at[person_id, 'un_sam_with_complications'] = False + df.at[person_id, 'un_am_tx_start_date'] = pd.NaT + # Start without treatment, treatment will be applied with HSI if care sought + df.at[person_id, 'un_am_treatment_type'] = 'none' + + # this will clear all wasting symptoms (applicable for SAM, not MAM) + self.sim.modules["SymptomManager"].clear_symptoms( + person_id=person_id, disease_module=self.module + ) + + +class Wasting_InitiateGrowthMonitoring(Event, PopulationScopeEventMixin): + # TODO: will be updated for children 1-5 (monitoring for 0-1 will be integrated in epi module) + # For now, children are only monitored if in population when sim. initiated, but when new child born, it is not + # scheduled for monitoring at all yet, it needs to be done in the epi module, or if better, done in epi for 0-1, + # and scheduled to be done in here from when they are 1y old + """ + Event that schedules HSI_Wasting_GrowthMonitoring for all under-5 children for a random day within the age-dependent + frequency. + """ + + def __init__(self, module): + """Runs only once, when simulation is initiated. + :param module: the module that created this event + """ + super().__init__(module) + assert isinstance(module, Wasting) + + def apply(self, population): + """Apply this event to the population. + :param population: the current population + """ + + df = population.props + rng = self.module.rng + p = self.module.parameters + + # TODO: including treated children? + index_under5 = df.index[df.is_alive & (df.age_exact_years < 5)] + # and ~df.un_am_treatment_type.isin(['standard_RUTF', 'soy_RUSF', 'CSB++', 'inpatient_care']) + + def get_monitoring_frequency_days(age): + if age <= 2: # TODO: expecting here, that 0-1 will be excluded and dealt with within epi module + return p['growth_monitoring_frequency_days'][0] + else: + return p['growth_monitoring_frequency_days'][1] + + # schedule monitoring within age-dependent frequency + for person_id in index_under5: + next_event_days = rng.randint(0, (get_monitoring_frequency_days(df.at[person_id, 'age_exact_years']) - 2)) + if (df.at[person_id, 'age_exact_years'] + (next_event_days / 365.25)) < 5: + self.sim.modules['HealthSystem'].schedule_hsi_event( + hsi_event=HSI_Wasting_GrowthMonitoring(module=self.module, person_id=person_id), + priority=2, topen=self.sim.date + pd.DateOffset(days=next_event_days) + ) + + +class HSI_Wasting_GrowthMonitoring(HSI_Event, IndividualScopeEventMixin): + """ Attendance is determined for the HSI. If the child attends, measurements with available equipment are performed + for that child. Based on these measurements, the child can be diagnosed as well/MAM/(un)complicated SAM and + eventually scheduled for the appropriate treatment. If the child (attending or not) is still under 5 at the time of + the next growth monitoring, the next event is scheduled with age-dependent frequency. + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + assert isinstance(module, Wasting) + + self.attendance = None + + self.TREATMENT_ID = "Undernutrition_GrowthMonitoring" + self.ACCEPTED_FACILITY_LEVEL = '1a' + + @property + def EXPECTED_APPT_FOOTPRINT(self): + """Return the expected appointment footprint based on attendance at the HSI event.""" + rng = self.module.rng + p = self.module.parameters + person_age = self.sim.population.props.loc[self.target].age_exact_years + + def get_attendance_prob(age): + if age <= 2: # TODO: expecting here, that 0-1 will be excluded and dealt with within epi module + return p['growth_monitoring_attendance_prob'][0] + else: + return p['growth_monitoring_attendance_prob'][1] + + # perform growth monitoring if attending + self.attendance = rng.random_sample() < get_attendance_prob(person_age) + if self.attendance: + return self.make_appt_footprint({'Under5OPD': 1}) + else: + return self.make_appt_footprint({}) + + def apply(self, person_id, squeeze_factor): + logger.debug(key='debug', data='This is HSI_Wasting_GrowthMonitoring') + + df = self.sim.population.props + rng = self.module.rng + p = self.module.parameters + + # TODO: Will they be monitored during the treatment? Can we assume, that after the treatment they will be + # always properly checked (all measurements and oedema checked), or should be the assumed "treatment outcome" + # be also based on equipment availability and probability of checking oedema? Maybe they should be sent for + # after treatment monitoring, where the assumed "treatment outcome" will be determined and follow-up treatment + # based on that? - The easiest way (currently coded) is assuming that after treatment all measurements are + # done, hence correctly diagnosed. The growth monitoring is scheduled for them as usual, ie, for instance, for + # a child 2-5 old, if they were sent for treatment via growth monitoring, they will be on treatment 3 or 4 + # weeks, but next monitoring will be done in ~5 months after the treatment. - Or we could schedule for the + # treated children a monitoring sooner after the treatment. + if (not df.at[person_id, 'is_alive']) or (df.at[person_id, 'age_exact_years'] >= 5): + # or + # df.at[person_id, 'un_am_treatment_type'].isin(['standard_RUTF', 'soy_RUSF', 'CSB++', 'inpatient_care']): + return + + def schedule_next_monitoring(): + def get_monitoring_frequency_days(age): + if age <= 2: # TODO: expecting here, that 0-1 will be excluded and dealt with within epi module + return p['growth_monitoring_frequency_days'][0] + else: + return p['growth_monitoring_frequency_days'][1] + + person_monitoring_frequency = get_monitoring_frequency_days(df.at[person_id, 'age_exact_years']) + if (df.at[person_id, 'age_exact_years'] + (person_monitoring_frequency / 365.25)) < 5: + # schedule next growth monitoring + self.sim.modules['HealthSystem'].schedule_hsi_event( + hsi_event=HSI_Wasting_GrowthMonitoring(module=self.module, person_id=person_id), + topen=self.sim.date + pd.DateOffset(days=person_monitoring_frequency), + tclose=None, + priority=2 + ) + + # TODO: as stated above, for now we schedule next monitoring for all children, even those sent for treatment + schedule_next_monitoring() + + if not self.attendance: + return + + available_equipment = [] + for equip in ['Height Pole (Stadiometer)', 'Weighing scale', 'MUAC tape']: + available = rng.random_sample() < HSI_Event.probability_all_equipment_available(self, equip) + if available: + available_equipment.append(equip) + self.add_equipment(set(available_equipment)) + + def schedule_tx_by_diagnosis(hsi_event): + self.sim.modules['HealthSystem'].schedule_hsi_event( + hsi_event=hsi_event(module=self.module, person_id=person_id), + priority=0, topen=self.sim.date + ) + + complications = df.at[person_id, 'un_sam_with_complications'] + oedema_checked = rng.random_sample() < 0.1 # TODO: find correct value & add as parameter p[''] + + # DIAGNOSIS + # based on performed measurements (depends on whether oedema is checked, and what equipment is available) + if oedema_checked and df.at[person_id, 'un_am_nutritional_oedema']: + diagnosis = 'SAM' + else: + if 'MUAC tape' in available_equipment: + # all equip available and used + if all(item in available_equipment for item in + ['Height Pole (Stadiometer)', 'Weighing scale']): + if oedema_checked: + diagnosis = df.at[person_id, 'un_clinical_acute_malnutrition'] + else: + whz = df.at[person_id, 'un_WHZ_category'] + muac = df.at[person_id, 'un_am_MUAC_category'] + if whz == 'WHZ>=-2' and muac == '>=125mm': + diagnosis = 'well' + elif whz == 'WHZ<-3' or muac == '<115mm': + diagnosis = 'SAM' + else: + diagnosis = 'MAM' + # MUAC measurement is solely used for diagnosis + else: + muac = df.at[person_id, 'un_am_MUAC_category'] + if muac == '>=125mm': + diagnosis = 'well' + elif muac == '<115mm': + diagnosis = 'SAM' + else: + diagnosis = 'MAM' + + else: # MUAC tape not available + # WHZ score is solely used for diagnosis + if all(item in available_equipment for item in + ['Height Pole (Stadiometer)', 'Weighing scale']): + whz = df.at[person_id, 'un_WHZ_category'] + if whz == 'WHZ>=-2': + diagnosis = 'well' + elif whz == 'WHZ<-3': + diagnosis = 'SAM' + else: + diagnosis = 'MAM' + # WHZ score nor MUAC measurement available, hence diagnosis based solely on presence of oedema + else: + if df.at[person_id, 'un_am_nutritional_oedema']: + diagnosis = 'SAM' + else: + diagnosis = 'well' + + if diagnosis == 'well': + return + elif diagnosis == 'MAM': + schedule_tx_by_diagnosis(HSI_Wasting_SupplementaryFeedingProgramme_MAM) + elif (diagnosis == 'SAM') and (not complications): + schedule_tx_by_diagnosis(HSI_Wasting_OutpatientTherapeuticProgramme_SAM) + else: # (diagnosis == 'SAM') and complications: + schedule_tx_by_diagnosis(HSI_Wasting_InpatientTherapeuticCare_ComplicatedSAM) + + def did_not_run(self): + logger.debug(key="HSI_Wasting_GrowthMonitoring", + data="HSI_Wasting_GrowthMonitoring: did not run" + ) pass - def on_birth(self, mother, child): + +class HSI_Wasting_SupplementaryFeedingProgramme_MAM(HSI_Event, IndividualScopeEventMixin): + """ + This is the supplementary feeding programme for MAM without complications + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + assert isinstance(module, Wasting) + + # Get a blank footprint and then edit to define call on resources + # of this treatment event + the_appt_footprint = self.sim.modules["HealthSystem"].get_blank_appt_footprint() + the_appt_footprint['Under5OPD'] = 1 # This requires one outpatient + + # Define the necessary information for an HSI + self.TREATMENT_ID = 'Undernutrition_Feeding_Supplementary' + self.EXPECTED_APPT_FOOTPRINT = the_appt_footprint + self.ACCEPTED_FACILITY_LEVEL = '1a' + self.ALERT_OTHER_DISEASES = [] + + def apply(self, person_id, squeeze_factor): df = self.sim.population.props - df.at[child, 'un_clinical_acute_malnutrition'] = 'well' - df.at[child, 'un_ever_wasted'] = False + # p = self.module.parameters + + if not df.at[person_id, 'is_alive']: + return + + # Do here whatever happens to an individual during this health system interaction event + # ~~~~~~~~~~~~~~~~~~~~~~ + # Make request for some consumables + consumables = self.sim.modules['HealthSystem'].parameters['item_and_package_code_lookups'] + # individual items + item_code1 = pd.unique(consumables.loc[consumables['Items'] == + 'Corn Soya Blend (or Supercereal - CSB++)', 'Item_Code'])[0] + + # check availability of consumables + if self.get_consumables([item_code1]): + logger.debug(key='debug', data='consumables are available') + # Log that the treatment is provided: + df.at[person_id, 'un_am_treatment_type'] = 'CSB++' + self.module.do_when_am_treatment(person_id, intervention='SFP') + else: + logger.debug(key='debug', + data=f"Consumable(s) not available, hence {self.TREATMENT_ID} cannot be provided.") + + def did_not_run(self): + logger.debug(key='debug', data=f'{self.TREATMENT_ID}: did not run') + pass + + +class HSI_Wasting_OutpatientTherapeuticProgramme_SAM(HSI_Event, IndividualScopeEventMixin): + """ + This is the outpatient management of SAM without any medical complications + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + assert isinstance(module, Wasting) + + # Get a blank footprint and then edit to define call on resources + # of this treatment event + the_appt_footprint = self.sim.modules["HealthSystem"].get_blank_appt_footprint() + the_appt_footprint['U5Malnutr'] = 1 + + # Define the necessary information for an HSI + self.TREATMENT_ID = 'Undernutrition_Feeding_Outpatient' + self.EXPECTED_APPT_FOOTPRINT = the_appt_footprint + self.ACCEPTED_FACILITY_LEVEL = '1a' + self.ALERT_OTHER_DISEASES = [] + + def apply(self, person_id, squeeze_factor): + df = self.sim.population.props + # p = self.module.parameters + + if not df.at[person_id, 'is_alive']: + return + + # Do here whatever happens to an individual during this health + # system interaction event + # ~~~~~~~~~~~~~~~~~~~~~~ + # Make request for some consumables + consumables = self.sim.modules['HealthSystem'].parameters[ + 'item_and_package_code_lookups'] + + # individual items + item_code1 = pd.unique(consumables.loc[consumables['Items'] == + 'SAM theraputic foods', 'Item_Code'])[0] + item_code2 = pd.unique(consumables.loc[consumables['Items'] == 'SAM medicines', 'Item_Code'])[0] + + # check availability of consumables + if self.get_consumables(item_code1) and self.get_consumables(item_code2): + logger.debug(key='debug', data='consumables are available.') + # Log that the treatment is provided: + df.at[person_id, 'un_am_treatment_type'] = 'standard_RUTF' + self.module.do_when_am_treatment(person_id, intervention='OTP') + else: + logger.debug(key='debug', + data=f"Consumable(s) not available, hence {self.TREATMENT_ID} cannot be provided.") + + def did_not_run(self): + logger.debug(key='debug', data=f'{self.TREATMENT_ID}: did not run') + pass + + +class HSI_Wasting_InpatientTherapeuticCare_ComplicatedSAM(HSI_Event, IndividualScopeEventMixin): + """ + This is the inpatient management of SAM with medical complications + """ + + def __init__(self, module, person_id): + super().__init__(module, person_id=person_id) + assert isinstance(module, Wasting) + + # Define the necessary information for an HSI + self.TREATMENT_ID = 'Undernutrition_Feeding_Inpatient' + self.EXPECTED_APPT_FOOTPRINT = self.make_appt_footprint({"U5Malnutr": 1}) + self.ACCEPTED_FACILITY_LEVEL = '2' + self.ALERT_OTHER_DISEASES = [] + self.BEDDAYS_FOOTPRINT = self.make_beddays_footprint({'general_bed': 7}) + + def apply(self, person_id, squeeze_factor): + df = self.sim.population.props + # p = self.module.parameters + + # Stop the person from dying of acute malnutrition (if they were going to die) + if not df.at[person_id, 'is_alive']: + return + + # Make request for some consumables + consumables = self.sim.modules['HealthSystem'].parameters['item_and_package_code_lookups'] + + # individual items + item_code1 = pd.unique( + consumables.loc[consumables['Items'] == 'SAM theraputic foods', 'Item_Code'])[0] + item_code2 = pd.unique(consumables.loc[consumables['Items'] == 'SAM medicines', 'Item_Code'])[0] + + # # check availability of consumables + if self.get_consumables(item_code1) and self.get_consumables(item_code2): + logger.debug(key='debug', data='consumables available, so use it.') + # Log that the treatment is provided: + df.at[person_id, 'un_am_treatment_type'] = 'inpatient_care' + self.module.do_when_am_treatment(person_id, intervention='ITC') + else: + logger.debug(key='debug', + data=f"Consumable(s) not available, hence {self.TREATMENT_ID} cannot be provided.") + + def did_not_run(self): + logger.debug(key='debug', data=f'{self.TREATMENT_ID}: did not run') + pass + + +class WastingModels: + """ houses all wasting linear models """ + + def __init__(self, module): + self.module = module + self.rng = module.rng + self.params = module.parameters + + # a linear model to predict the probability of individual's recovery from moderate acute malnutrition + self.acute_malnutrition_recovery_mam_lm = LinearModel.multiplicative( + Predictor('un_am_treatment_type', + conditions_are_mutually_exclusive=True, conditions_are_exhaustive=True) + .when('soy_RUSF', self.params['recovery_rate_with_soy_RUSF']) + .when('CSB++', self.params['recovery_rate_with_CSB++']) + ) + + # a linear model to predict the probability of individual's recovery from severe acute malnutrition + self.acute_malnutrition_recovery_sam_lm = LinearModel.multiplicative( + Predictor('un_am_treatment_type', + conditions_are_mutually_exclusive=True, conditions_are_exhaustive=True) + .when('standard_RUTF', self.params['recovery_rate_with_standard_RUTF']) + .when('inpatient_care', self.params['recovery_rate_with_inpatient_care']) + ) + + # Linear model for the probability of progression to severe wasting (age-dependent only) + # (natural history only, no interventions) + self.severe_wasting_progression_lm = LinearModel.multiplicative( + Predictor('age_exact_years', + conditions_are_mutually_exclusive=True, conditions_are_exhaustive=False) + .when('<0.5', self.params['progression_severe_wasting_by_agegp'][0]) + .when('.between(0.5,1, inclusive="left")', self.params['progression_severe_wasting_by_agegp'][1]) + .when('.between(1,2, inclusive="left")', self.params['progression_severe_wasting_by_agegp'][2]) + .when('.between(2,3, inclusive="left")', self.params['progression_severe_wasting_by_agegp'][3]) + .when('.between(3,4, inclusive="left")', self.params['progression_severe_wasting_by_agegp'][4]) + .when('.between(4,5, inclusive="left")', self.params['progression_severe_wasting_by_agegp'][5]) + ) + + # get wasting incidence linear model + self.wasting_incidence_lm = self.get_wasting_incidence() + + # Linear model for the probability of death due to SAM + self.death_due_to_sam_lm = LinearModel.multiplicative( + Predictor('age_exact_years', + conditions_are_mutually_exclusive=True, conditions_are_exhaustive=False) + .when('<0.5', self.params['death_rate_untreated_SAM_by_agegp'][0]) + .when('.between(0.5,1, inclusive="left")', self.params['death_rate_untreated_SAM_by_agegp'][1]) + .when('.between(1,2, inclusive="left")', self.params['death_rate_untreated_SAM_by_agegp'][2]) + .when('.between(2,3, inclusive="left")', self.params['death_rate_untreated_SAM_by_agegp'][3]) + .when('.between(3,4, inclusive="left")', self.params['death_rate_untreated_SAM_by_agegp'][4]) + .when('.between(4,5, inclusive="left")', self.params['death_rate_untreated_SAM_by_agegp'][5]), + Predictor().when('un_clinical_acute_malnutrition != "SAM"', 0), + ) + + def get_wasting_incidence(self) -> LinearModel: + """ + :return: a scaled wasting incidence linear model amongst young children + """ + df = self.module.sim.population.props + + def unscaled_wasting_incidence_lm(intercept: Union[float, int] = 1.0) -> LinearModel: + # linear model to predict the incidence of wasting + return LinearModel( + LinearModelType.MULTIPLICATIVE, + intercept, + Predictor('age_exact_years', + conditions_are_mutually_exclusive=True, conditions_are_exhaustive=False) + .when('<0.5', self.params['base_inc_rate_wasting_by_agegp'][0]) + .when('.between(0.5,1, inclusive="left")', self.params['base_inc_rate_wasting_by_agegp'][1]) + .when('.between(1,2, inclusive="left")', self.params['base_inc_rate_wasting_by_agegp'][2]) + .when('.between(2,3, inclusive="left")', self.params['base_inc_rate_wasting_by_agegp'][3]) + .when('.between(3,4, inclusive="left")', self.params['base_inc_rate_wasting_by_agegp'][4]) + .when('.between(4,5, inclusive="left")', self.params['base_inc_rate_wasting_by_agegp'][5]), + Predictor().when('(nb_size_for_gestational_age == "small_for_gestational_age") ' + '& (nb_late_preterm == False) & (nb_early_preterm == False)', + self.params['rr_wasting_SGA_and_term']), + Predictor().when('(nb_size_for_gestational_age == "small_for_gestational_age") ' + '& (nb_late_preterm == True) | (nb_early_preterm == True)', + self.params['rr_wasting_SGA_and_preterm']), + Predictor().when('(nb_size_for_gestational_age == "average_for_gestational_age") ' + '& (nb_late_preterm == True) | (nb_early_preterm == True)', + self.params['rr_wasting_preterm_and_AGA']), + Predictor('li_wealth').apply( + lambda x: 1 if x == 1 else (x - 1) ** (self.params['rr_wasting_wealth_level'])), + ) + + unscaled_lm = unscaled_wasting_incidence_lm() + target_mean = self.params['base_inc_rate_wasting_by_agegp'][2] # base inc rate for 12-23mo old + actual_mean = unscaled_lm.predict(df.loc[df.is_alive & (df.age_years == 1) & + (df.un_WHZ_category != 'WHZ>=-2')]).mean() + + scaled_intercept = 1.0 * (target_mean / actual_mean) \ + if (target_mean != 0 and actual_mean != 0 and ~np.isnan(actual_mean)) else 1.0 + scaled_wasting_incidence_lm = unscaled_wasting_incidence_lm(intercept=scaled_intercept) + return scaled_wasting_incidence_lm + + def get_wasting_prevalence(self, agegp: str) -> LinearModel: + """ + :param agegp: children's age group + :return: a scaled wasting prevalence linear model amongst young children less than 5 years + """ + df = self.module.sim.population.props + + def unscaled_wasting_prevalence_lm(intercept: Union[float, int]) -> LinearModel: + return LinearModel( + LinearModelType.LOGISTIC, + intercept, # baseline odds: get_odds_wasting(agegp=agegp) + Predictor('li_wealth', + conditions_are_mutually_exclusive=True, conditions_are_exhaustive=False) + .when(2, self.params['or_wasting_hhwealth_Q2']) + .when(3, self.params['or_wasting_hhwealth_Q3']) + .when(4, self.params['or_wasting_hhwealth_Q4']) + .when(5, self.params['or_wasting_hhwealth_Q5']), + Predictor().when('(nb_size_for_gestational_age == "small_for_gestational_age") ' + '& (nb_late_preterm == False) & (nb_early_preterm == False)', + self.params['or_wasting_SGA_and_term']), + Predictor().when('(nb_size_for_gestational_age == "small_for_gestational_age") ' + '& (nb_late_preterm == True) | (nb_early_preterm == True)', + self.params['or_wasting_SGA_and_preterm']), + Predictor().when('(nb_size_for_gestational_age == "average_for_gestational_age") ' + '& (nb_late_preterm == True) | (nb_early_preterm == True)', + self.params['or_wasting_preterm_and_AGA']) + ) + + get_odds_wasting = self.module.get_odds_wasting(agegp=agegp) + unscaled_lm = unscaled_wasting_prevalence_lm(intercept=get_odds_wasting) + target_mean = self.module.get_odds_wasting(agegp='12_23mo') + actual_mean = unscaled_lm.predict(df.loc[df.is_alive & (df.age_years == 1)]).mean() + scaled_intercept = get_odds_wasting * (target_mean / actual_mean) if \ + (target_mean != 0 and actual_mean != 0 and ~np.isnan(actual_mean)) else get_odds_wasting + scaled_wasting_prevalence_lm = unscaled_wasting_prevalence_lm(intercept=scaled_intercept) + + return scaled_wasting_prevalence_lm + + +class Wasting_LoggingEvent(RegularEvent, PopulationScopeEventMixin): + """ + This Event logs the number of incident cases that have occurred since the previous logging event. + Analysis scripts expect that the frequency of this logging event is once per year. + """ + + def __init__(self, module): + # This event to occur every year + self.repeat = 12 + super().__init__(module, frequency=DateOffset(months=self.repeat)) + self.date_last_run = self.sim.date + + def apply(self, population): + df = self.sim.population.props + + # ----- INCIDENCE LOG ---------------- + # Convert the list of timestamps into a number of timestamps + # and check that all the dates have occurred since self.date_last_run + inc_df = pd.DataFrame(index=self.module.wasting_incident_case_tracker.keys(), + columns=self.module.wasting_states) + for age_grp in self.module.wasting_incident_case_tracker.keys(): + for state in self.module.wasting_states: + inc_df.loc[age_grp, state] = len(self.module.wasting_incident_case_tracker[age_grp][state]) + assert all(date >= self.date_last_run for + date in self.module.wasting_incident_case_tracker[age_grp][state]) + + logger.info(key='wasting_incidence_count', data=inc_df.to_dict()) + + # Reset the tracker and the date_last_run + self.module.wasting_incident_case_tracker = copy.deepcopy(self.module.wasting_incident_case_tracker_blank) + self.date_last_run = self.sim.date + + # ----- LENGTH LOG ---------------- + # Convert the list of lengths to an avg length + # and check that all the lengths are positive + length_df = pd.DataFrame(index=self.module.wasting_length_tracker.keys(), + columns=self.module.recovery_options) + for age_grp in self.module.wasting_length_tracker.keys(): + for recov_opt in self.module.recovery_options: + if self.module.wasting_length_tracker[age_grp][recov_opt]: + length_df.loc[age_grp, recov_opt] = (sum(self.module.wasting_length_tracker[age_grp][recov_opt]) / + len(self.module.wasting_length_tracker[age_grp][recov_opt])) + else: + length_df.loc[age_grp, recov_opt] = 0 + assert not np.isnan(length_df.loc[age_grp, recov_opt]) + assert all(length > 0 for length in self.module.wasting_length_tracker[age_grp][recov_opt]) + + # Reset the tracker + self.module.wasting_incident_case_tracker = copy.deepcopy(self.module.wasting_incident_case_tracker_blank) + + under5s = df.loc[df.is_alive & (df.age_exact_years < 5)] + above5s = df.loc[df.is_alive & (df.age_exact_years >= 5)] + + for age_ys in range(6): + age_grp = self.module.age_grps.get(age_ys, '5+y') + + # get those children who are wasted + if age_ys < 5: + mod_wasted_whole_ys_agegrp = under5s[( + under5s.age_years.between(age_ys, age_ys + 1, inclusive='left') & + (under5s.un_WHZ_category == '-3<=WHZ<-2') + )] + sev_wasted_whole_ys_agegrp = under5s[( + under5s.age_years.between(age_ys, age_ys + 1, inclusive='left') & + (under5s.un_WHZ_category == 'WHZ<-3') + )] + else: + mod_wasted_whole_ys_agegrp = above5s[( + above5s.un_WHZ_category == '-3<=WHZ<-2' + )] + sev_wasted_whole_ys_agegrp = above5s[( + above5s.un_WHZ_category == 'WHZ<-3' + )] + mod_wasted_whole_ys_agegrp['wasting_length'] = \ + (self.sim.date - mod_wasted_whole_ys_agegrp['un_last_wasting_date_of_onset']).dt.days + sev_wasted_whole_ys_agegrp['wasting_length'] = \ + (self.sim.date - sev_wasted_whole_ys_agegrp['un_last_wasting_date_of_onset']).dt.days + if len(mod_wasted_whole_ys_agegrp) > 0: + assert not np.isnan(mod_wasted_whole_ys_agegrp['wasting_length']).all() + assert all(length > 0 for length in mod_wasted_whole_ys_agegrp['wasting_length']) + length_df.loc[age_grp, 'mod_not_yet_recovered'] = ( + sum(mod_wasted_whole_ys_agegrp['wasting_length']) / len(mod_wasted_whole_ys_agegrp['wasting_length']) + ) + else: + length_df.loc[age_grp, 'mod_not_yet_recovered'] = 0 + assert not np.isnan(length_df.loc[age_grp, 'mod_not_yet_recovered']) + if len(sev_wasted_whole_ys_agegrp) > 0: + assert not np.isnan(sev_wasted_whole_ys_agegrp['wasting_length']).all() + assert all(length > 0 for length in sev_wasted_whole_ys_agegrp['wasting_length']) + length_df.loc[age_grp, 'sev_not_yet_recovered'] = ( + sum(sev_wasted_whole_ys_agegrp['wasting_length']) / len(sev_wasted_whole_ys_agegrp['wasting_length']) + ) + else: + length_df.loc[age_grp, 'sev_not_yet_recovered'] = 0 + assert not np.isnan(length_df.loc[age_grp, 'sev_not_yet_recovered']) + + logger.debug(key='wasting_length_avg', data=length_df.to_dict()) + + # ----- PREVALENCE LOG ---------------- + # Wasting totals (prevalence & pop size at logging time) + # declare a dictionary that will hold proportions of wasting prevalence per each age group + wasting_prev_dict: Dict[str, Any] = dict() + # declare a dictionary that will hold pop sizes + pop_sizes_dict: Dict[str, Any] = dict() + + # loop through different age groups and get proportions of wasting prevalence per each age group + for low_bound_mos, high_bound_mos in [(0, 5), (6, 11), (12, 23), (24, 35), (36, 47), (48, 59)]: # in months + low_bound_age_in_years = low_bound_mos / 12.0 + high_bound_age_in_years = (1 + high_bound_mos) / 12.0 + total_per_agegrp_nmb = (under5s.age_exact_years.between(low_bound_age_in_years, high_bound_age_in_years, + inclusive='left')).sum() + if total_per_agegrp_nmb > 0: + # get those children who are wasted + mod_wasted_agegrp_nmb = (under5s.age_exact_years.between(low_bound_age_in_years, high_bound_age_in_years, + inclusive='left') & (under5s.un_WHZ_category + == '-3<=WHZ<-2')).sum() + sev_wasted_agegrp_nmb = (under5s.age_exact_years.between(low_bound_age_in_years, high_bound_age_in_years, + inclusive='left') & (under5s.un_WHZ_category + == 'WHZ<-3')).sum() + # add moderate and severe wasting prevalence to the dictionary + wasting_prev_dict[f'mod__{low_bound_mos}_{high_bound_mos}mo'] = \ + mod_wasted_agegrp_nmb / total_per_agegrp_nmb + wasting_prev_dict[f'sev__{low_bound_mos}_{high_bound_mos}mo'] = \ + sev_wasted_agegrp_nmb / total_per_agegrp_nmb + else: + # add zero moderate and severe wasting prevalence to the dictionary + wasting_prev_dict[f'mod__{low_bound_mos}_{high_bound_mos}mo'] = 0 + wasting_prev_dict[f'sev__{low_bound_mos}_{high_bound_mos}mo'] = 0 + + # add pop sizes to the dataframe + pop_sizes_dict[f'mod__{low_bound_mos}_{high_bound_mos}mo'] = mod_wasted_agegrp_nmb + pop_sizes_dict[f'sev__{low_bound_mos}_{high_bound_mos}mo'] = sev_wasted_agegrp_nmb + pop_sizes_dict[f'total__{low_bound_mos}_{high_bound_mos}mo'] = total_per_agegrp_nmb + # log prevalence & pop size for children above 5y + above5s = df.loc[df.is_alive & (df.age_exact_years >= 5)] + assert (len(under5s) + len(above5s)) == len(df.loc[df.is_alive]) + mod_wasted_above5_nmb = (above5s.un_WHZ_category == '-3<=WHZ<-2').sum() + sev_wasted_above5_nmb = (above5s.un_WHZ_category == 'WHZ<-3').sum() + wasting_prev_dict['mod__5y+'] = mod_wasted_above5_nmb / len(above5s) + wasting_prev_dict['sev__5y+'] = sev_wasted_above5_nmb / len(above5s) + pop_sizes_dict['mod__5y+'] = mod_wasted_above5_nmb + pop_sizes_dict['sev__5y+'] = sev_wasted_above5_nmb + pop_sizes_dict['total__5y+'] = len(above5s) + + # add to dictionary proportion of all moderately/severely wasted children under 5 years + mod_under5_nmb = (under5s.un_WHZ_category == '-3<=WHZ<-2').sum() + sev_under5_nmb = (under5s.un_WHZ_category == 'WHZ<-3').sum() + wasting_prev_dict['total_mod_under5_prop'] = mod_under5_nmb / len(under5s) + wasting_prev_dict['total_sev_under5_prop'] = sev_under5_nmb / len(under5s) + pop_sizes_dict['mod__under5'] = mod_under5_nmb + pop_sizes_dict['sev__under5'] = sev_under5_nmb + pop_sizes_dict['total__under5'] = len(under5s) + + # log wasting prevalence + logger.info(key='wasting_prevalence_props', data=wasting_prev_dict) + + # log pop sizes + logger.info(key='pop sizes', data=pop_sizes_dict) + + +class Wasting_InitLoggingEvent(Event, PopulationScopeEventMixin): + """ + This Event logs the number of incident cases that have occurred since the previous logging event. + Analysis scripts expect that the frequency of this logging event is once per year. + """ + + def __init__(self, module): + # This event to occur every year + super().__init__(module) + + def apply(self, population): + df = self.sim.population.props + + # ----- PREVALENCE LOG ---------------- + # Wasting totals (prevalence & pop size at logging time) + # declare a dictionary that will hold proportions of wasting prevalence per each age group + wasting_prev_dict: Dict[str, Any] = dict() + # declare a dictionary that will hold pop sizes + pop_sizes_dict: Dict[str, Any] = dict() + + under5s = df.loc[df.is_alive & (df.age_exact_years < 5)] + # loop through different age groups and get proportions of wasting prevalence per each age group + for low_bound_mos, high_bound_mos in [(0, 5), (6, 11), (12, 23), (24, 35), (36, 47), (48, 59)]: # in months + low_bound_age_in_years = low_bound_mos / 12.0 + high_bound_age_in_years = (1 + high_bound_mos) / 12.0 + # get those children who are wasted + mod_wasted_agegrp_nmb = (under5s.age_exact_years.between(low_bound_age_in_years, high_bound_age_in_years, + inclusive='left') & (under5s.un_WHZ_category + == '-3<=WHZ<-2')).sum() + sev_wasted_agegrp_nmb = (under5s.age_exact_years.between(low_bound_age_in_years, high_bound_age_in_years, + inclusive='left') & (under5s.un_WHZ_category + == 'WHZ<-3')).sum() + total_per_agegrp_nmb = (under5s.age_exact_years.between(low_bound_age_in_years, high_bound_age_in_years, + inclusive='left')).sum() + # add moderate and severe wasting prevalence to the dictionary + wasting_prev_dict[f'mod__{low_bound_mos}_{high_bound_mos}mo'] = mod_wasted_agegrp_nmb / total_per_agegrp_nmb + wasting_prev_dict[f'sev__{low_bound_mos}_{high_bound_mos}mo'] = sev_wasted_agegrp_nmb / total_per_agegrp_nmb + # add pop sizes to the dataframe + pop_sizes_dict[f'mod__{low_bound_mos}_{high_bound_mos}mo'] = mod_wasted_agegrp_nmb + pop_sizes_dict[f'sev__{low_bound_mos}_{high_bound_mos}mo'] = sev_wasted_agegrp_nmb + pop_sizes_dict[f'total__{low_bound_mos}_{high_bound_mos}mo'] = total_per_agegrp_nmb + # log prevalence & pop size for children above 5y + above5s = df.loc[df.is_alive & (df.age_exact_years >= 5)] + assert (len(under5s) + len(above5s)) == len(df.loc[df.is_alive]) + mod_wasted_above5_nmb = (above5s.un_WHZ_category == '-3<=WHZ<-2').sum() + sev_wasted_above5_nmb = (above5s.un_WHZ_category == 'WHZ<-3').sum() + wasting_prev_dict['mod__5y+'] = mod_wasted_above5_nmb / len(above5s) + wasting_prev_dict['sev__5y+'] = sev_wasted_above5_nmb / len(above5s) + pop_sizes_dict['mod__5y+'] = mod_wasted_above5_nmb + pop_sizes_dict['sev__5y+'] = sev_wasted_above5_nmb + pop_sizes_dict['total__5y+'] = len(above5s) + + # add to dictionary proportion of all moderately/severely wasted children under 5 years + mod_under5_nmb = (under5s.un_WHZ_category == '-3<=WHZ<-2').sum() + sev_under5_nmb = (under5s.un_WHZ_category == 'WHZ<-3').sum() + wasting_prev_dict['total_mod_under5_prop'] = mod_under5_nmb / len(under5s) + wasting_prev_dict['total_sev_under5_prop'] = sev_under5_nmb / len(under5s) + pop_sizes_dict['mod__under5'] = mod_under5_nmb + pop_sizes_dict['sev__under5'] = sev_under5_nmb + pop_sizes_dict['total__under5'] = len(under5s) + + # log wasting prevalence + logger.info(key='wasting_init_prevalence_props', data=wasting_prev_dict) + + # log pop sizes + logger.info(key='init pop sizes', data=pop_sizes_dict) + diff --git a/tests/test_wasting.py b/tests/test_wasting.py new file mode 100644 index 0000000000..d99671d01b --- /dev/null +++ b/tests/test_wasting.py @@ -0,0 +1,894 @@ +""" + Basic tests for the Wasting Module + """ +import os +from pathlib import Path + +import pandas as pd +import pytest +from pandas import DateOffset + +from tlo import Date, Module, Simulation, logging +from tlo.events import PopulationScopeEventMixin, RegularEvent +from tlo.lm import LinearModel, LinearModelType +from tlo.methods import ( + Metadata, + demography, + enhanced_lifestyle, + healthburden, + healthseekingbehaviour, + healthsystem, + hsi_generic_first_appts, + simplified_births, + symptommanager, + wasting, +) +from tlo.methods.healthseekingbehaviour import HealthSeekingBehaviourPoll +from tlo.methods.wasting import ( + HSI_Wasting_InpatientTherapeuticCare_ComplicatedSAM, + HSI_Wasting_OutpatientTherapeuticProgramme_SAM, + Wasting_ClinicalAcuteMalnutritionRecovery_Event, + Wasting_IncidencePoll, + Wasting_NaturalRecovery_Event, + Wasting_ProgressionToSevere_Event, + Wasting_SevereAcuteMalnutritionDeath_Event, + Wasting_UpdateToMAM_Event, +) + +# Path to the resource files used by the disease and intervention methods +resourcefilepath = Path(os.path.dirname(__file__)) / '../resources' + +# Default date for the start of simulations +start_date = Date(2010, 1, 1) +end_date = Date(2011, 1, 1) + + +def get_sim(tmpdir): + """ + Return simulation objection with Wasting and other necessary + modules registered. + """ + sim = Simulation(start_date=start_date, seed=0, + show_progress_bar=False, + log_config={ + 'filename': 'tmp', + 'directory': tmpdir, + 'custom_levels': { + "*": logging.WARNING, + "tlo.methods.wasting": logging.INFO} + }) + + sim.register(demography.Demography(resourcefilepath=resourcefilepath), + enhanced_lifestyle.Lifestyle(resourcefilepath=resourcefilepath), + healthsystem.HealthSystem(resourcefilepath=resourcefilepath, + disable=False, + cons_availability='all'), + symptommanager.SymptomManager(resourcefilepath=resourcefilepath), + healthseekingbehaviour.HealthSeekingBehaviour(resourcefilepath=resourcefilepath), + healthburden.HealthBurden(resourcefilepath=resourcefilepath), + simplified_births.SimplifiedBirths(resourcefilepath=resourcefilepath), + wasting.Wasting(resourcefilepath=resourcefilepath) + ) + return sim + + +@pytest.mark.slow +def test_basic_run(tmpdir): + """Run the simulation and do some daily checks on dtypes and properties integrity """ + class DummyModule(Module): + """ A Dummy module that ensure wasting properties are as expected on a daily basis """ + METADATA = {Metadata.DISEASE_MODULE} + + def read_parameters(self, data_folder): + pass + + def initialise_population(self, population): + pass + + def initialise_simulation(self, sim): + # schedule check property integrity event + sim.schedule_event(CheckPropertyIntegrityEvent(self), sim.date) + + def on_birth(self, mother_id, child_id): + pass + + class CheckPropertyIntegrityEvent(RegularEvent, PopulationScopeEventMixin): + def __init__(self, module): + """schedule to run every day + :param module: the module that created this event + """ + self.repeat_days = 1 + super().__init__(module, frequency=DateOffset(days=self.repeat_days)) + assert isinstance(module, DummyModule) + + def apply(self, population): + """ Apply this event to the population. + :param population: the current population + """ + # check datatypes + self.check_dtypes(population) + + # check properties are as expected + self.check_configuration_of_properties(population) + + def check_dtypes(self, population): + # Check types of columns + df = population.props + orig = population.new_row + assert (df.dtypes == orig.dtypes).all() + + def check_configuration_of_properties(self, population): + """ check wasting properties on a daily basis to ensure integrity """ + df = population.props + under5_sam = df.index[df.is_alive & (df.age_exact_years < 5) & + (df.un_clinical_acute_malnutrition == 'SAM')] + + # Those that were never wasted, should have normal WHZ score: + assert (df.loc[~df.un_ever_wasted & ~df.date_of_birth.isna(), 'un_WHZ_category'] == 'WHZ>=-2').all() + + # Those for whom the death date has past should be dead + assert not df.loc[(df['un_sam_death_date'] < self.sim.date), 'is_alive'].any() + # Those who died due to SAM should have SAM + assert (df.loc[(df['un_sam_death_date']) < self.sim.date, 'un_clinical_acute_malnutrition'] == 'SAM').all() + + # Check that those in a current episode have symptoms of wasting + # [caused by the wasting module] but not others (among those alive) + has_symptoms_of_wasting = set(self.sim.modules['SymptomManager'].who_has('weight_loss')) + + has_symptoms = set([p for p in has_symptoms_of_wasting if + 'Wasting' in sim.modules['SymptomManager'].causes_of(p, 'weight_loss')]) + + in_current_episode_before_recovery = df.is_alive & df.un_ever_wasted & (df.un_last_wasting_date_of_onset <= + self.sim.date) & (self.sim.date <= + df.un_am_recovery_date) + set_of_person_id_in_current_episode_before_recovery = set(in_current_episode_before_recovery[ + in_current_episode_before_recovery].index) + + in_current_episode_before_death = df.is_alive & df.un_ever_wasted & (df.un_last_wasting_date_of_onset <= + self.sim.date) & ( + self.sim.date <= df.un_sam_death_date) + set_of_person_id_in_current_episode_before_death = set(in_current_episode_before_death[ + in_current_episode_before_death].index) + + assert set() == set_of_person_id_in_current_episode_before_recovery.intersection( + set_of_person_id_in_current_episode_before_death) + + # WHZ standard deviation of -3, oedema, and MUAC <115mm should cause severe acute malnutrition + whz_index = df.index[df['un_WHZ_category'] == 'WHZ<-3'] + oedema_index = df.index[df['un_am_nutritional_oedema']] + muac_index = df.index[df['un_am_MUAC_category'] == '<115mm'] + assert (df.loc[whz_index, 'un_clinical_acute_malnutrition'] == "SAM").all() + assert (df.loc[oedema_index, 'un_clinical_acute_malnutrition'] == "SAM").all() + assert (df.loc[muac_index, 'un_clinical_acute_malnutrition'] == "SAM").all() + + # all SAM individuals should have symptoms of wasting + assert set(under5_sam).issubset(has_symptoms) + + # All MAM individuals should have no symptoms of wasting + assert set(df.index[df.is_alive & (df.age_exact_years < 5) & + (df.un_clinical_acute_malnutrition == 'MAM')]) not in has_symptoms + + popsize = 10_000 + sim = get_sim(tmpdir) + sim.register(DummyModule()) + sim.make_initial_population(n=popsize) + sim.simulate(end_date=end_date) + + +def test_wasting_incidence(tmpdir): + """Check Incidence of wasting is happening as expected """ + # get simulation object: + dur = pd.DateOffset(days=0) + popsize = 1000 + sim = get_sim(tmpdir) + + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + + # reset properties of all individuals so that they are not wasted + df = sim.population.props + df.loc[df.is_alive, 'un_WHZ_category'] = 'WHZ>=-2' # not undernourished + df.loc[df.is_alive, 'un_ever_wasted'] = False + df.loc[df.is_alive, 'un_last_wasting_date_of_onset'] = pd.NaT + + # Set incidence of wasting at 100% + sim.modules['Wasting'].wasting_models.wasting_incidence_lm = LinearModel.multiplicative() + + # Run polling event: check that all children should now have moderate wasting: + polling = Wasting_IncidencePoll(sim.modules['Wasting']) + polling.apply(sim.population) + + # Check properties of individuals: should now be moderately wasted + under5s = df.loc[df.is_alive & (df['age_years'] < 5)] + assert all(under5s['un_ever_wasted']) + assert all(under5s['un_WHZ_category'] == '-3<=WHZ<-2') + assert all(under5s['un_last_wasting_date_of_onset'] == sim.date) + + +def test_report_daly_weights(tmpdir): + """Check if daly weights reporting is done as expected. Four checks are made: + 1. For an individual who is well (No weight is expected/must be 0.0) + 2. For an individual with moderate wasting and oedema (expected daly weight is 0.051) + 3. For an individual with severe wasting and oedema (expected daly weight is 0.172) + 4. For an individual with severe wasting without oedema (expected daly weight is 0.128)""" + + dur = pd.DateOffset(days=0) + popsize = 1 + sim = get_sim(tmpdir) + sim.modules['Demography'].parameters['max_age_initial'] = 4.9 + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + + # Dict to hold the DALY weights + daly_wts = dict() + + # Get person to use + df = sim.population.props + person_id = df.index[0] + df.at[person_id, 'is_alive'] = True + + # Check daly weight for not undernourished person (weight is 0.0) + # Reset diagnostic properties + df.loc[person_id, 'un_WHZ_category'] = 'WHZ>=-2' + df.loc[person_id, 'un_am_nutritional_oedema'] = False + df.loc[person_id, 'un_am_MUAC_category'] = '>=125mm' + + # Verify diagnosis - an individual should be well + sim.modules["Wasting"].clinical_acute_malnutrition_state(person_id, df) + assert df.loc[person_id, 'un_clinical_acute_malnutrition'] == 'well' + + # Report daly weight for this individual + daly_weights_reported = sim.modules["Wasting"].report_daly_values() + + # Verify that individual has no daly weight + assert daly_weights_reported.loc[person_id] == 0.0 + + get_daly_weights = sim.modules['HealthBurden'].get_daly_weight + + # Check daly weight for person with moderate wasting and oedema (weight is 0.051) + # Reset diagnostic properties + df.loc[person_id, 'un_WHZ_category'] = '-3<=WHZ<-2' + df.loc[person_id, 'un_am_nutritional_oedema'] = True + + # Verify diagnosis - an individual should be SAM + sim.modules["Wasting"].clinical_acute_malnutrition_state(person_id, df) + assert df.loc[person_id, 'un_clinical_acute_malnutrition'] == 'SAM' + + # Report daly weight for this individual + daly_weights_reported = sim.modules["Wasting"].report_daly_values() + + # Get daly weight of moderate wasting with oedema + daly_wts['mod_wasting_with_oedema'] = get_daly_weights(sequlae_code=461) + + # Compare the daly weight of this individual with the daly weight obtained from HealthBurden module + assert daly_wts['mod_wasting_with_oedema'] == daly_weights_reported.loc[person_id] + + # Check daly weight for person with severe wasting and oedema (weight is 0.172) + # Reset diagnostic properties + df.loc[person_id, 'un_WHZ_category'] = 'WHZ<-3' + df.loc[person_id, 'un_am_nutritional_oedema'] = True + + # Verify diagnosis - an individual should be SAM + sim.modules["Wasting"].clinical_acute_malnutrition_state(person_id, df) + assert df.loc[person_id, 'un_clinical_acute_malnutrition'] == 'SAM' + + # Report daly weight for this individual + daly_weights_reported = sim.modules["Wasting"].report_daly_values() + + # Get daly weight of severe wasting with oedema + daly_wts['sev_wasting_with_oedema'] = get_daly_weights(sequlae_code=463) + + # Compare the daly weight of this individual with the daly weight obtained from HealthBurden module + assert daly_wts['sev_wasting_with_oedema'] == daly_weights_reported.loc[person_id] + + # Check daly weight for person with severe wasting without oedema (weight is 0.128) + # Reset diagnosis + df.loc[person_id, 'un_WHZ_category'] = 'WHZ<-3' + df.loc[person_id, 'un_am_nutritional_oedema'] = False + + # Verify diagnosis - an individual should be SAM + sim.modules["Wasting"].clinical_acute_malnutrition_state(person_id, df) + assert df.loc[person_id, 'un_clinical_acute_malnutrition'] == 'SAM' + + # Report daly weight for this individual + daly_weights_reported = sim.modules["Wasting"].report_daly_values() + + # Get day weight of severe wasting without oedema + daly_wts['sev_wasting_w/o_oedema'] = get_daly_weights(sequlae_code=462) + + # Compare the daly weight of this individual with the daly weight obtained from HealthBurden module + assert daly_wts['sev_wasting_w/o_oedema'] == daly_weights_reported.loc[person_id] + + +def test_recovery_moderate_wasting(tmpdir): + """Check natural recovery of moderate wasting """ + dur = pd.DateOffset(days=0) + popsize = 1000 + sim = get_sim(tmpdir) + + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + sim.event_queue.queue = [] # clear the queue + + # Get person to use: + df = sim.population.props + under5s = df.loc[df.is_alive & (df['age_years'] < 5)] + person_id = under5s.index[0] + # make this individual have no wasting + df.loc[person_id, 'un_WHZ_category'] = 'WHZ>=-2' + # confirm wasting property is reset. This individual should have no wasting + assert df.loc[person_id, 'un_WHZ_category'] == 'WHZ>=-2' + + # Set incidence of wasting at 100% + sim.modules['Wasting'].wasting_models.wasting_incidence_lm = LinearModel.multiplicative() + + # Run Wasting Polling event: This event should cause all young children to be moderate wasting + polling = Wasting_IncidencePoll(module=sim.modules['Wasting']) + polling.apply(sim.population) + + # Check properties of this individual: should now be moderately wasted + person = df.loc[person_id] + assert person['un_ever_wasted'] + assert person['un_WHZ_category'] == '-3<=WHZ<-2' + assert person['un_last_wasting_date_of_onset'] == sim.date + assert pd.isnull(person['un_am_tx_start_date']) + assert pd.isnull(person['un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + # Check that there is a Wasting_NaturalRecovery_Event scheduled + # for this person + recov_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) + if isinstance(event_tuple[1], Wasting_NaturalRecovery_Event)][0] + date_of_scheduled_recov = recov_event_tuple[0] + recov_event = recov_event_tuple[1] + assert date_of_scheduled_recov > sim.date + + # Run the recovery event: + sim.date = date_of_scheduled_recov + recov_event.apply(person_id=person_id) + + # Check properties of this individual + person = df.loc[person_id] + assert person['un_WHZ_category'] == 'WHZ>=-2' + if df.at[person_id, 'un_clinical_acute_malnutrition'] == 'well': + assert person['un_am_recovery_date'] == sim.date + else: + assert pd.isnull(df.at[person_id, 'un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + +def test_recovery_severe_acute_malnutrition_without_complications(tmpdir): + """ Check the onset of symptoms with SAM, check recovery to MAM with tx when + the progression to severe wasting is certain, hence no natural recovery from moderate wasting, + the natural death due to SAM is certain, hence no natural recovery from severe wasting, + and check death canceled and symptoms resolved when recovered to MAM with tx. """ + dur = pd.DateOffset(days=0) + popsize = 1000 + sim = get_sim(tmpdir) + + # get wasting module + wmodule = sim.modules['Wasting'] + + # set death due to untreated SAM at 100%, hence no natural recovery from severe wasting + wmodule.parameters['death_rate_untreated_SAM_by_agegp'] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0] + + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + sim.event_queue.queue = [] # clear the queue + + # Get person to use: + df = sim.population.props + under5s = df.loc[df.is_alive & (df['age_years'] < 5)] + person_id = under5s.index[0] + # Manually set this individual properties to be well + df.loc[person_id, 'un_WHZ_category'] = 'WHZ>=-2' + df.loc[person_id, 'un_am_MUAC_category'] = '>=125mm' + df.loc[person_id, 'un_am_nutritional_oedema'] = False + df.loc[df.is_alive, 'un_clinical_acute_malnutrition'] = 'well' + df.at[person_id, 'un_sam_with_complications'] = False + df.at[person_id, 'un_sam_death_date'] = pd.NaT + + # ensure the individual has no complications when SAM occurs + wmodule.parameters['prob_complications_in_SAM'] = 0.0 + + # set incidence of wasting at 100% + wmodule.wasting_models.wasting_incidence_lm = LinearModel.multiplicative() + + # set progress to severe wasting at 100% as well, hence no natural recovery from moderate wasting + wmodule.wasting_models.severe_wasting_progression_lm = LinearModel.multiplicative() + + # set complete recovery from wasting to zero. We want those with SAM to recover to MAM with tx + wmodule.wasting_models.acute_malnutrition_recovery_sam_lm = LinearModel(LinearModelType.MULTIPLICATIVE, 0.0) + + # set prob of death after tx at 0% and recovery to MAM at 100% + wmodule.parameters['prob_death_after_SAMcare'] = 0.0 + wmodule.parameters['prob_mam_after_SAMcare'] = 1.0 + + # Run Wasting Polling event to get new incident cases: + polling = Wasting_IncidencePoll(module=sim.modules['Wasting']) + polling.apply(sim.population) + + # Check properties of this individual: should now be moderately wasted + person = df.loc[person_id] + assert person['un_WHZ_category'] == '-3<=WHZ<-2' + + # Check that there is a Wasting_ProgressionToSevere_Event scheduled for this person: + progression_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) + if isinstance(event_tuple[1], Wasting_ProgressionToSevere_Event)][0] + date_of_scheduled_progression = progression_event_tuple[0] + progression_event = progression_event_tuple[1] + assert date_of_scheduled_progression > sim.date + + # Run the progression to severe wasting event: + sim.date = date_of_scheduled_progression + progression_event.apply(person_id) + + # Check this individual has symptom (weight loss) caused by Wasting (SAM only) + assert 'weight_loss' in sim.modules['SymptomManager'].has_what( + person_id=person_id, disease_module=sim.modules['Wasting'] + ) + + # Check properties of this individual + # (should now be severely wasted, diagnosed as SAM + person = df.loc[person_id] + assert person['un_WHZ_category'] == 'WHZ<-3' + assert person['un_clinical_acute_malnutrition'] == 'SAM' + + hsp = HealthSeekingBehaviourPoll(sim.modules['HealthSeekingBehaviour']) + hsp.run() + + # check non-emergency care event is scheduled + assert isinstance(sim.modules['HealthSystem'].find_events_for_person(person_id)[0][1], + hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt) + + # Run the created instance of HSI_GenericFirstApptAtFacilityLevel0 and check care was sought + ge = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) if + isinstance(ev[1], hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt)][0] + ge.run(squeeze_factor=0.0) + + # check HSI event is scheduled + hsi_event_scheduled = [ + ev + for ev in sim.modules["HealthSystem"].find_events_for_person(person_id) + if isinstance(ev[1], HSI_Wasting_OutpatientTherapeuticProgramme_SAM) + ] + assert 1 == len(hsi_event_scheduled) + + # Run the created instance of HSI_Wasting_OutpatientTherapeuticProgramme_SAM and check care was sought + sam_ev = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) if + isinstance(ev[1], HSI_Wasting_OutpatientTherapeuticProgramme_SAM)][0] + sam_ev.run(squeeze_factor=0.0) + + # Check death is scheduled, but was canceled with tx + person = df.loc[person_id] + assert isinstance(sim.find_events_for_person(person_id)[1][1], Wasting_SevereAcuteMalnutritionDeath_Event) + assert pd.isnull(person['un_sam_death_date']) + # get date of death and death event + death_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) if + isinstance(event_tuple[1], Wasting_SevereAcuteMalnutritionDeath_Event)][0] + date_of_scheduled_death = death_event_tuple[0] + death_event = death_event_tuple[1] + assert date_of_scheduled_death > sim.date + + # Check recovery to MAM due to tx is scheduled + assert isinstance(sim.find_events_for_person(person_id)[2][1], Wasting_UpdateToMAM_Event) + # get date of recovery to MAM and the recovery event + sam_recovery_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) if + isinstance(event_tuple[1], Wasting_UpdateToMAM_Event)][0] + date_of_scheduled_recovery_to_mam = sam_recovery_event_tuple[0] + sam_recovery_event = sam_recovery_event_tuple[1] + assert date_of_scheduled_recovery_to_mam > sim.date + + # Run death event (death should not happen) & recovery to MAM in correct order + sim.date = min(date_of_scheduled_death, date_of_scheduled_recovery_to_mam) + if sim.date == date_of_scheduled_death: + death_event.apply(person_id) + sim.date = date_of_scheduled_recovery_to_mam + sam_recovery_event.apply(person_id) + else: + sam_recovery_event.apply(person_id) + sim.date = date_of_scheduled_death + death_event.apply(person_id) + + # Check properties of this individual + person = df.loc[person_id] + assert person['is_alive'] + assert pd.isnull(person['un_sam_death_date']) + assert person['un_clinical_acute_malnutrition'] == 'MAM' + # check they have no symptoms: + assert 0 == len(sim.modules['SymptomManager'].has_what(person_id=person_id, disease_module=sim.modules['Wasting'])) + + +def test_recovery_severe_acute_malnutrition_with_complications(tmpdir): + """ test individual's recovery from wasting with complications """ + dur = pd.DateOffset(days=0) + popsize = 1000 + sim = get_sim(tmpdir) + + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + sim.event_queue.queue = [] # clear the queue + + # Get person to use: + df = sim.population.props + under5s = df.loc[df.is_alive & (df['age_years'] < 5)] + person_id = under5s.index[0] + + # get wasting module + wmodule = sim.modules['Wasting'] + + # set death due to untreated SAM at 100%, hence no natural recovery from severe wasting + wmodule.parameters['death_rate_untreated_SAM_by_agegp'] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0] + + # Manually set this individual properties to have + # severe acute malnutrition with complications + df.loc[person_id, 'un_WHZ_category'] = 'WHZ<-3' + + # ensure the individual has complications due to SAM + wmodule.parameters['prob_complications_in_SAM'] = 1.0 + + # assign diagnosis + wmodule.clinical_acute_malnutrition_state(person_id, df) + + # by having severe wasting, this individual should be diagnosed as SAM + assert df.loc[person_id, 'un_clinical_acute_malnutrition'] == 'SAM' + + # symptoms should be applied + assert person_id in set(sim.modules['SymptomManager'].who_has('weight_loss')) + + # should have complications + assert df.at[person_id, 'un_sam_with_complications'] + + # make full recovery rate to 100% and death rate to zero so that + # this individual should recover + wmodule.wasting_models.acute_malnutrition_recovery_sam_lm = LinearModel.multiplicative() + wmodule.parameters['prob_death_after_SAMcare'] = 0.0 + + # run care seeking event and ensure HSI for complicated SAM is scheduled + hsp = HealthSeekingBehaviourPoll(sim.modules['HealthSeekingBehaviour']) + hsp.run() + + # check non-emergency care event is scheduled + assert isinstance(sim.modules['HealthSystem'].find_events_for_person(person_id)[0][1], + hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt) + + # Run the created instance of HSI_GenericFirstApptAtFacilityLevel0 and check care was sought + ge = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) if + isinstance(ev[1], hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt)][0] + ge.run(squeeze_factor=0.0) + + # check HSI event for complicated SAM is scheduled + hsi_event_scheduled = [ + ev + for ev in sim.modules["HealthSystem"].find_events_for_person(person_id) + if isinstance(ev[1], HSI_Wasting_InpatientTherapeuticCare_ComplicatedSAM) + ] + assert 1 == len(hsi_event_scheduled) + + # Run the created instance of HSI_Wasting_OutpatientTherapeuticProgramme_SAM and check care was sought + sam_ev = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) if + isinstance(ev[1], HSI_Wasting_InpatientTherapeuticCare_ComplicatedSAM)][0] + sam_ev.run(squeeze_factor=0.0) + + # Check death is scheduled, but was canceled due to tx + person = df.loc[person_id] + print(f"{sim.find_events_for_person(person_id)=}") + assert isinstance(sim.find_events_for_person(person_id)[0][1], Wasting_SevereAcuteMalnutritionDeath_Event) + assert pd.isnull(person['un_sam_death_date']) + # get date of death and death event + death_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) if + isinstance(event_tuple[1], Wasting_SevereAcuteMalnutritionDeath_Event)][0] + date_of_scheduled_death = death_event_tuple[0] + death_event = death_event_tuple[1] + + # Check full recovery due to tx is scheduled + assert isinstance(sim.find_events_for_person(person_id)[1][1], Wasting_ClinicalAcuteMalnutritionRecovery_Event) + # get date of full recovery and the recovery event + sam_recovery_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) if + isinstance(event_tuple[1], Wasting_ClinicalAcuteMalnutritionRecovery_Event)][0] + date_of_scheduled_full_recovery = sam_recovery_event_tuple[0] + sam_recovery_event = sam_recovery_event_tuple[1] + assert date_of_scheduled_full_recovery > sim.date + + # Run death event (death should not happen) & full recovery in correct order + sim.date = min(date_of_scheduled_death, date_of_scheduled_full_recovery) + if sim.date == date_of_scheduled_death: + death_event.apply(person_id) + sim.date = date_of_scheduled_full_recovery + sam_recovery_event.apply(person_id) + else: + sam_recovery_event.apply(person_id) + sim.date = date_of_scheduled_death + death_event.apply(person_id) + + # Check properties of this individual. Should now be well + person = df.loc[person_id] + assert person['un_WHZ_category'] == 'WHZ>=-2' + assert (person['un_am_MUAC_category'] == '>=125mm') + assert pd.isnull(person['un_sam_death_date']) + + # check they have no symptoms: + assert 0 == len(sim.modules['SymptomManager'].has_what(person_id=person_id, disease_module=sim.modules['Wasting'])) + + +def test_nat_hist_death(tmpdir): + """ Check: Wasting onset --> death """ + """ Check if the risk of death is 100% does everyone with SAM die? """ + dur = pd.DateOffset(days=0) + popsize = 1000 + sim = get_sim(tmpdir) + + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + sim.event_queue.queue = [] # clear the queue + + # get wasting module + wmodule = sim.modules['Wasting'] + + # Set death rate at 100% + wmodule.parameters['prob_death_after_SAMcare'] = 1.0 + wmodule.parameters['prob_mam_after_SAMcare'] = 0.0 + + # make zero recovery rate. reset recovery linear model + wmodule.wasting_models.acute_malnutrition_recovery_sam_lm = LinearModel(LinearModelType.MULTIPLICATIVE, 0.0) + + # Get the children to use: + df = sim.population.props + under5s = df.loc[df.is_alive & (df['age_years'] < 5)] + person_id = under5s.index[0] + + # make an individual diagnosed as SAM by WHZ category. + # We want to make this individual qualify for death + df.loc[person_id, 'un_WHZ_category'] = 'WHZ<-3' + + # assign diagnosis + wmodule.clinical_acute_malnutrition_state(person_id, df) + + # apply wasting symptoms to this individual + wmodule.wasting_clinical_symptoms(person_id) + + # check symptoms are applied + assert person_id in set(sim.modules['SymptomManager'].who_has('weight_loss')) + + # run health seeking behavior and ensure non-emergency event is scheduled + hsp = HealthSeekingBehaviourPoll(sim.modules['HealthSeekingBehaviour']) + hsp.run() + + # check non-emergency care event is scheduled + assert isinstance(sim.modules['HealthSystem'].find_events_for_person(person_id)[0][1], + hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt) + + # Run the created instance of HSI_GenericFirstApptAtFacilityLevel0 and check care was sought + ge = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) + if isinstance(ev[1], hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt)][0] + ge.run(squeeze_factor=0.0) + + # check outpatient care event is scheduled + hsi_event_scheduled = [ + ev + for ev in sim.modules["HealthSystem"].find_events_for_person(person_id) + if isinstance(ev[1], HSI_Wasting_OutpatientTherapeuticProgramme_SAM) + ] + assert 1 == len(hsi_event_scheduled) + + # Run the created instance of HSI_Wasting_OutpatientTherapeuticProgramme_SAM and check care was sought + sam_ev = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) + if isinstance(ev[1], HSI_Wasting_OutpatientTherapeuticProgramme_SAM)][0] + sam_ev.run(squeeze_factor=0.0) + + # since there is zero recovery rate, check death event is scheduled + assert isinstance(sim.find_events_for_person(person_id)[0][1], Wasting_SevereAcuteMalnutritionDeath_Event) + + # # Run the acute death event and ensure the person is now dead: + death_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) + if isinstance(event_tuple[1], Wasting_SevereAcuteMalnutritionDeath_Event)][0] + date_of_scheduled_death = death_event_tuple[0] + death_event = death_event_tuple[1] + assert date_of_scheduled_death > sim.date + sim.date = date_of_scheduled_death + death_event.apply(person_id=person_id) + + # Check properties of this individual: (should now be dead) + person = df.loc[person_id] + assert not pd.isnull(person['un_sam_death_date']) + assert person['un_sam_death_date'] == sim.date + assert not person['is_alive'] + + +def test_nat_hist_cure_if_recovery_scheduled(tmpdir): + """ Show that if a cure event is run before when a person was going to recover naturally, it causes the episode + to end earlier. """ + + dur = pd.DateOffset(days=0) + popsize = 1000 + sim = get_sim(tmpdir) + + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + sim.event_queue.queue = [] # clear the queue + + wmodule = sim.modules['Wasting'] + # Make 0% death rate by replacing with empty linear model 0.0 + wmodule.parameters['prob_death_after_SAMcare'] = 0.0 + wmodule.parameters['prob_mam_after_SAMcare'] = 1.0 + + # increase wasting incidence rate to 100% and reduce rate of progress to severe wasting to zero. We don't want + # individuals to progress to SAM as we are testing for MAM natural recovery + wmodule.wasting_models.wasting_incidence_lm = LinearModel.multiplicative() + wmodule.wasting_models.severe_wasting_progression_lm = LinearModel(LinearModelType.MULTIPLICATIVE, 0.0) + + # Get person to use: + df = sim.population.props + under5s = df.loc[df.is_alive & (df['age_years'] < 5)] + person_id = under5s.index[0] + assert df.loc[person_id, 'un_WHZ_category'] == 'WHZ>=-2' + + # Run Wasting Polling event to get new incident cases: + polling = Wasting_IncidencePoll(module=sim.modules['Wasting']) + polling.apply(sim.population) + + # Check properties of this individual: (should now be moderately wasted without progression to severe) + person = df.loc[person_id] + assert person['un_ever_wasted'] + assert person['un_WHZ_category'] == '-3<=WHZ<-2' + assert person['un_last_wasting_date_of_onset'] == sim.date + assert pd.isnull(person['un_am_tx_start_date']) + assert pd.isnull(person['un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + # Check that there is a Wasting_NaturalRecovery_Event scheduled for this person: + recov_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) + if isinstance(event_tuple[1], Wasting_NaturalRecovery_Event)][0] + date_of_scheduled_recov = recov_event_tuple[0] + recov_event = recov_event_tuple[1] + assert date_of_scheduled_recov > sim.date + + # Run a Cure Event + cure_event = Wasting_ClinicalAcuteMalnutritionRecovery_Event(person_id=person_id, module=sim.modules['Wasting']) + cure_event.apply(person_id=person_id) + + # Check that the person is not wasted and is alive still: + person = df.loc[person_id] + assert person['is_alive'] + assert person['un_WHZ_category'] == 'WHZ>=-2' + assert not pd.isnull(person['un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + # Run the recovery event that was originally scheduled - this should have no effect + sim.date = date_of_scheduled_recov + recov_event.apply(person_id=person_id) + person = df.loc[person_id] + assert person['is_alive'] + assert person['un_WHZ_category'] == 'WHZ>=-2' + assert not pd.isnull(person['un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + +def test_nat_hist_cure_if_death_scheduled(tmpdir): + """Show that if a cure event is run before when a person was going to die, it causes the episode to end without + the person dying.""" + + dur = pd.DateOffset(days=0) + popsize = 1000 + sim = get_sim(tmpdir) + + # get wasting module + wmodule = sim.modules['Wasting'] + + # set death due to untreated SAM at 0%, hence always scheduled natural recovery from severe wasting + wmodule.parameters['death_rate_untreated_SAM_by_agegp'] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + + sim.make_initial_population(n=popsize) + sim.simulate(end_date=start_date + dur) + sim.event_queue.queue = [] # clear the queue + + # increase to 100% wasting incidence, progress to severe wasting, and death rate after SAM care; + # set full recovery ad recovery to MAM with SAM care at 0% + wmodule.wasting_models.wasting_incidence_lm = LinearModel.multiplicative() + wmodule.wasting_models.severe_wasting_progression_lm = LinearModel.multiplicative() + wmodule.wasting_models.acute_malnutrition_recovery_sam_lm = LinearModel(LinearModelType.MULTIPLICATIVE, 0.0) + wmodule.parameters['prob_mam_after_SAMcare'] = 0.0 + wmodule.parameters['prob_death_after_SAMcare'] = 1.0 + + # Get person to use: + df = sim.population.props + under5s = df.loc[df.is_alive & (df['age_years'] < 5)] + person_id = under5s.index[0] + # Manually set this individual properties to be well + df.loc[person_id, 'un_WHZ_category'] = 'WHZ>=-2' + df.loc[person_id, 'un_am_MUAC_category'] = '>=125mm' + df.loc[person_id, 'un_am_nutritional_oedema'] = False + df.loc[df.is_alive, 'un_clinical_acute_malnutrition'] = 'well' + df.at[person_id, 'un_sam_with_complications'] = False + df.at[person_id, 'un_sam_death_date'] = pd.NaT + + # Run Wasting Polling event to get new incident cases: + polling = Wasting_IncidencePoll(module=sim.modules['Wasting']) + polling.apply(sim.population) + + # Check properties of this individual: (should now be moderately wasted with a scheduled progression to severe date) + person = df.loc[person_id] + assert person['un_ever_wasted'] + assert person['un_WHZ_category'] == '-3<=WHZ<-2' + assert person['un_last_wasting_date_of_onset'] == sim.date + assert pd.isnull(person['un_am_tx_start_date']) + assert pd.isnull(person['un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + # Check that there is a Wasting_ProgressionToSevere_Event scheduled for this person + progression_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) + if isinstance(event_tuple[1], Wasting_ProgressionToSevere_Event)][0] + date_of_scheduled_progression = progression_event_tuple[0] + progression_event = progression_event_tuple[1] + assert date_of_scheduled_progression > sim.date + + # Run the progression to severe wasting event: + sim.date = date_of_scheduled_progression + progression_event.apply(person_id=person_id) + + # Check properties of this individual: (should now be severely wasted and without a scheduled death date) + person = df.loc[person_id] + assert person['un_ever_wasted'] + assert person['un_WHZ_category'] == 'WHZ<-3' + assert person['un_clinical_acute_malnutrition'] == 'SAM' + assert pd.isnull(person['un_am_tx_start_date']) + assert pd.isnull(person['un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + # run health seeking behavior and ensure non-emergency event is scheduled + hsp = HealthSeekingBehaviourPoll(sim.modules['HealthSeekingBehaviour']) + hsp.run() + + # check non-emergency care event is scheduled + assert isinstance(sim.modules['HealthSystem'].find_events_for_person(person_id)[0][1], + hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt) + + # Run the created instance of HSI_GenericFirstApptAtFacilityLevel0 and check care was sought + ge = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) if + isinstance(ev[1], hsi_generic_first_appts.HSI_GenericNonEmergencyFirstAppt)][0] + ge.run(squeeze_factor=0.0) + + # check outpatient care event is scheduled + hsi_event_scheduled = [ + ev + for ev in sim.modules["HealthSystem"].find_events_for_person(person_id) + if isinstance(ev[1], HSI_Wasting_OutpatientTherapeuticProgramme_SAM) + ] + assert 1 == len(hsi_event_scheduled) + + # Run the created instance of HSI_Wasting_OutpatientTherapeuticProgramme_SAM and check care was sought + sam_ev = [ev[1] for ev in sim.modules['HealthSystem'].find_events_for_person(person_id) if + isinstance(ev[1], HSI_Wasting_OutpatientTherapeuticProgramme_SAM)][0] + sam_ev.run(squeeze_factor=0.0) + + # since there is no natural death, natural recovery should be scheduled, and + # since there is zero recovery rate with tx, death event after care should be scheduled + print(f"{sim.find_events_for_person(person_id)=}") + assert isinstance(sim.find_events_for_person(person_id)[1][1], Wasting_NaturalRecovery_Event) or \ + isinstance(sim.find_events_for_person(person_id)[2][1], Wasting_NaturalRecovery_Event) + assert isinstance(sim.find_events_for_person(person_id)[1][1], Wasting_SevereAcuteMalnutritionDeath_Event) or \ + isinstance(sim.find_events_for_person(person_id)[2][1], Wasting_SevereAcuteMalnutritionDeath_Event) + + # Check a date of death is scheduled. it should be any date in the future: + death_event_tuple = [event_tuple for event_tuple in sim.find_events_for_person(person_id) + if isinstance(event_tuple[1], Wasting_SevereAcuteMalnutritionDeath_Event)][0] + date_of_scheduled_death = death_event_tuple[0] + death_event = death_event_tuple[1] + assert date_of_scheduled_death > sim.date + + # Run a Cure Event now + cure_event = Wasting_ClinicalAcuteMalnutritionRecovery_Event(person_id=person_id, module=sim.modules['Wasting']) + cure_event.apply(person_id=person_id) + + # Check that the person is not wasted and is alive still: + person = df.loc[person_id] + assert person['is_alive'] + assert person['un_WHZ_category'] == 'WHZ>=-2' + assert not pd.isnull(person['un_am_recovery_date']) + assert pd.isnull(person['un_sam_death_date']) + + # Run the death event that was originally scheduled - this should have no effect and the person should not die + sim.date = date_of_scheduled_death + death_event.apply(person_id=person_id) + person = df.loc[person_id] + assert person['is_alive']