From dae3cd9f376dd4d5594f9d683174e806377e56f2 Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Thu, 22 Feb 2024 10:43:54 +0000 Subject: [PATCH 1/8] Include task-shifting in mode 2 --- .../ResourceFile_HealthSystem_parameters.csv | 4 +- src/tlo/methods/healthsystem.py | 109 ++++++++++++++- tests/test_healthsystem.py | 125 ++++++++++++++++++ 3 files changed, 231 insertions(+), 7 deletions(-) diff --git a/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv b/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv index 2828a9376c..3144bd7380 100644 --- a/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv +++ b/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e36cbd225191f893f2de5f0f34adc251046af5ec58daaf7c86b09a6c83c1e31d -size 379 +oid sha256:cd5b508bfbb7b430cc05f116fd92df79e4663aff9d480e4ebd94dfa73530677e +size 420 diff --git a/src/tlo/methods/healthsystem.py b/src/tlo/methods/healthsystem.py index 470242262c..3bcf4a0369 100644 --- a/src/tlo/methods/healthsystem.py +++ b/src/tlo/methods/healthsystem.py @@ -563,6 +563,10 @@ class HealthSystem(Module): Types.BOOL, "Decide whether to scale HR capabilities by population size every year. Can be used as well as" " the dynamic_HR_scaling_factor" ), + + 'include_task_shifting': Parameter( + Types.BOOL, "Decide whether to allow for task-shifting in mode 2" + ), 'tclose_overwrite': Parameter( Types.INT, "Decide whether to overwrite tclose variables assigned by disease modules"), @@ -599,6 +603,7 @@ def __init__( beds_availability: Optional[str] = None, randomise_queue: bool = True, ignore_priority: bool = False, + include_task_shifting: bool = False, policy_name: Optional[str] = None, capabilities_coefficient: Optional[float] = None, use_funded_or_actual_staffing: Optional[str] = None, @@ -625,6 +630,8 @@ def __init__( and priority :param ignore_priority: If ``True`` do not use the priority information in HSI event to schedule + :param include_task_shifting: If ``True`` when in mode 2 consider task-shifting of officers if one originally + required is not available :param policy_name: Name of priority policy that will be adopted if any :param capabilities_coefficient: Multiplier for the capabilities of health officers, if ``None`` set to ratio of initial population to estimated 2010 @@ -658,6 +665,14 @@ def __init__( assert not (ignore_priority and policy_name is not None), ( 'Cannot adopt a priority policy if the priority will be then ignored' ) + + # Global task-shifting options. The key in the dictionary refers to the officer + # eligible for task shifting, while the values refer to the officers that can take + # over the officer's tasks. The numbers refer to the factor by which appt time will + # have to be scaled if task is performed by alternative officer. + self.global_task_shifting = { + 'Pharmacy': (['Nursing_and_Midwifery', 'Clinical'], [1.5,1]), + } self.disable = disable self.disable_and_reject_all = disable_and_reject_all @@ -673,6 +688,8 @@ def __init__( self.randomise_queue = randomise_queue self.ignore_priority = ignore_priority + + self.include_task_shifting = include_task_shifting # This default value will be overwritten if assumed policy is not None self.lowest_priority_considered = 2 @@ -2367,7 +2384,7 @@ def process_events_mode_0_and_1(self, hold_over: List[HSIEventQueueItem]) -> Non hold_over.extend(_to_be_held_over) def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: - + capabilities_monitor = Counter(self.module.capabilities_today.to_dict()) set_capabilities_still_available = {k for k, v in capabilities_monitor.items() if v > 0.0} @@ -2445,17 +2462,58 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: # based on queue information, and we assume no squeeze ever takes place. squeeze_factor = 0. - # Check if any of the officers required have run out. + # Check if any of the officers required have run out. If including task-shifting, + # this will involve checking if alternative capabilities are available for officers + # that are no longer available. out_of_resources = False + + # This dictionary stores all task-shifting officers considered for this appointment + task_shifting_adopted = {} for officer, call in original_call.items(): - # If any of the officers are not available, then out of resources + # If any of the officers are not available, then out of resources, unless these can be + # task-shifted if officer not in set_capabilities_still_available: + + # Set this to True for now, however if: + # 1. Task-shifting is included, + # 2. Task-shifting is available for this officer/treatment, + # 3. Alternative officers are still available + # then will reset to False out_of_resources = True + + if self.sim.modules['HealthSystem'].include_task_shifting: + + # Get officer type only + officer_no_facility = officer.split("Officer_")[1] + + # Extract task-shifting options for this officer + task_shift_options_for_officer = self.sim.modules['HealthSystem'].global_task_shifting.get(officer_no_facility) + + # Check if possible alternatives to officer are available + # If they are, must register that we'll be using alternative. + if task_shift_options_for_officer: + + # Unpack values + new_officer_no_facility, time_scaling = task_shift_options_for_officer + + for new_officer_no_facility, time_scaling in zip(new_officer_no_facility, time_scaling): + # Get the task-shifting officer + shift_target_officer = officer.replace(officer_no_facility, new_officer_no_facility) + + # Check if this task-shifting officer is available, and save task_shift + if shift_target_officer in set_capabilities_still_available: + # Record task-shifting + task_shifting_adopted[officer] = (shift_target_officer, time_scaling) + out_of_resources = False + + # Once we've found available officer to replace, no need to go through other + # options + break + # If officers still available, run event. Note: in current logic, a little # overtime is allowed to run last event of the day. This seems more realistic # than medical staff leaving earlier than # planned if seeing another patient would take them into overtime. - if out_of_resources: # Do not run, @@ -2499,6 +2557,7 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: f"Cannot run HSI {event.TREATMENT_ID} without facility_info being defined." # Expected appt footprint before running event + # NOTE-TO-ADD: This appt footprint needs to reflect that a different officer was used _appt_footprint_before_running = event.EXPECTED_APPT_FOOTPRINT # Run event & get actual footprint actual_appt_footprint = event.run(squeeze_factor=squeeze_factor) @@ -2521,7 +2580,40 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: # Recalculate call on officers based on squeeze factor. for k in updated_call.keys(): updated_call[k] = updated_call[k]/(squeeze_factor + 1.) - + + # Recalculate call on officers including task shifting, which may result in + # a change to required time + if task_shifting_adopted: + updated_call_inc_task_shift = {} + # Go over all officers in updated_call + for k in updated_call.keys(): + + # If task-shifting was requested for this officer, change name + # of officer and rescale original task by relevant factor + if k in task_shifting_adopted.keys(): + + task_for_officer = updated_call[k]*task_shifting_adopted[k][1] + j = task_shifting_adopted[k][0] + + if j in updated_call_inc_task_shift.keys(): + # If officer is already included in updated_call_inc_task_shift + # (e.g. because it was already performing own tasks as well as + # taking over that of officer not available) add to original task + updated_call_inc_task_shift[j] += task_for_officer + else: + updated_call_inc_task_shift[j] = task_for_officer + + # Else simply add original requirement to new call + else: + if k in updated_call_inc_task_shift.keys(): + # Ensure that if this officer already present in dictionary + # this task is added, not overwritten + updated_call_inc_task_shift[k] += updated_call[k] + else: + updated_call_inc_task_shift[k] = updated_call[k] + + updated_call = updated_call_inc_task_shift + # Subtract this from capabilities used so-far today capabilities_monitor.subtract(updated_call) @@ -2543,6 +2635,8 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: self.module.running_total_footprint += updated_call # Write to the log + # WARNING: the logged appt footprint does not contain information + # on whether task-shifting was performed or not. self.module.record_hsi_event( hsi_event=event, actual_appt_footprint=actual_appt_footprint, @@ -2550,6 +2644,7 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: did_run=True, priority=_priority ) + # Don't have any capabilities at all left for today, no # point in going through the queue to check what's left to do today. @@ -2827,6 +2922,7 @@ class HealthSystemChangeParameters(Event, PopulationScopeEventMixin): """Event that causes certain internal parameters of the HealthSystem to be changed; specifically: * `mode_appt_constraints` * `ignore_priority` + * `include_task_shifting` * `capabilities_coefficient` * `cons_availability` * `beds_availability` @@ -2843,6 +2939,9 @@ def apply(self, population): if 'ignore_priority' in self._parameters: self.module.ignore_priority = self._parameters['ignore_priority'] + + if 'include_task_shifting' in self._parameters: + self.module.include_task_shifting = self._parameters['include_task_shifting'] if 'capabilities_coefficient' in self._parameters: self.module.capabilities_coefficient = self._parameters['capabilities_coefficient'] diff --git a/tests/test_healthsystem.py b/tests/test_healthsystem.py index e07a2d5889..c0619f1680 100644 --- a/tests/test_healthsystem.py +++ b/tests/test_healthsystem.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd import pytest +import re from tlo import Date, Module, Simulation, logging from tlo.analysis.hsi_events import get_details_of_defined_hsi_events @@ -1257,6 +1258,7 @@ def test_HealthSystemChangeParameters(seed, tmpdir): initial_parameters = { 'mode_appt_constraints': 0, 'ignore_priority': False, + 'include_task_shifting': False, 'capabilities_coefficient': 0.5, 'cons_availability': 'all', 'beds_availability': 'default', @@ -1264,6 +1266,7 @@ def test_HealthSystemChangeParameters(seed, tmpdir): new_parameters = { 'mode_appt_constraints': 2, 'ignore_priority': True, + 'include_task_shifting': True, 'capabilities_coefficient': 1.0, 'cons_availability': 'none', 'beds_availability': 'none', @@ -1279,6 +1282,7 @@ def apply(self, population): _params = dict() _params['mode_appt_constraints'] = hs.mode_appt_constraints _params['ignore_priority'] = hs.ignore_priority + _params['include_task_shifting'] = hs.include_task_shifting _params['capabilities_coefficient'] = hs.capabilities_coefficient _params['cons_availability'] = hs.consumables.cons_availability _params['beds_availability'] = hs.bed_days.availability @@ -1952,6 +1956,127 @@ def apply(self, person_id, squeeze_factor): assert (Nran_w_priority2 == int(tot_population/4)) & (Nran_w_priority3 == 0) +def test_task_shifting_in_mode_2(seed, tmpdir): + """Test that in mode 2 task-shifting takes place as expected even if capabilities for + required officer are no longer available, provided an alternative officer still is. + By "as expected" we mean that the first alternative officer listed is chosen preferentially. + """ + + # Create Dummy Module to host the HSI + class DummyModule(Module): + METADATA = {Metadata.DISEASE_MODULE, Metadata.USES_HEALTHSYSTEM} + + def read_parameters(self, data_folder): + pass + + def initialise_population(self, population): + pass + + def initialise_simulation(self, sim): + pass + + # Create a dummy HSI event class + class DummyHSIEvent(HSI_Event, IndividualScopeEventMixin): + def __init__(self, module, person_id, appt_type, level): + super().__init__(module, person_id=person_id) + self.TREATMENT_ID = 'DummyHSIEvent' + self.EXPECTED_APPT_FOOTPRINT = self.make_appt_footprint({appt_type: 1}) + self.ACCEPTED_FACILITY_LEVEL = level + + self.this_hsi_event_ran = False + + def apply(self, person_id, squeeze_factor): + self.this_hsi_event_ran = True + + log_config = { + "filename": "log", + "directory": tmpdir, + "custom_levels": {"tlo.methods.healthsystem": logging.DEBUG}, + } + sim = Simulation(start_date=start_date, seed=seed, log_config=log_config) + + # Register the core modules and simulate for 0 days + sim.register(demography.Demography(resourcefilepath=resourcefilepath), + healthsystem.HealthSystem(resourcefilepath=resourcefilepath, + capabilities_coefficient=1.0, + mode_appt_constraints=2, + include_task_shifting=True, + ignore_priority=False, + randomise_queue=True, + policy_name="", + use_funded_or_actual_staffing='funded_plus'), + DummyModule() + ) + + tot_population = 100 + sim.make_initial_population(n=tot_population) + sim.simulate(end_date=sim.start_date) + + # Get pointer to the HealthSystemScheduler event + healthsystemscheduler = sim.modules['HealthSystem'].healthsystemscheduler + + # Force entire population in one district (keys_district[0]) and get facility ID + person_for_district = {d: i for i, d in enumerate(sim.population.props['district_of_residence'].cat.categories)} + keys_district = list(person_for_district.keys()) + + for i in range(0, int(tot_population)): + sim.population.props.at[i, 'district_of_residence'] = keys_district[0] + + # Get facility ID + facID = int((re.search(r'\d+', next(iter(hsi1.expected_time_requests)))).group()) + + # Schedule an identical appointment for all individuals + for i in range(0, tot_population): + + hsi = DummyHSIEvent(module=sim.modules['DummyModule'], + person_id=i, + appt_type='MinorSurg', + level='1a') + + sim.modules['HealthSystem'].schedule_hsi_event( + hsi, + topen=sim.date, + tclose=sim.date + pd.DateOffset(days=1), + # Assign equal priority + priority=0 + ) + + hsi1 = DummyHSIEvent(module=sim.modules['DummyModule'], + person_id=0, # Ensures call is on officers in first district + appt_type='MinorSurg', + level='1a') + hsi1.initialise() + + pharmacy_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Pharmacy'] + nursing_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] + clinical_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Clinical'] + + # Check that first choice of task-shifting officer for Pharmacy is Nursing_and_Midwifery, and get their factor + assert 'Nursing_and_Midwifery' == sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[0][0] + nursing_task_shift_factor = sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[1][0] + + # Number of appts that want to see delivered if pharmacy time is set to zero, + # clinical time is set to perform 50 appts, and nursing time is set to perform 50 appts including + # both nursing and pharmacy tasks. + Ntarget = 50 + + sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Pharmacy'] = 0.0 + sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Clinical'] = Ntarget*(clinical_task_time) + sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] = Ntarget*(nursing_task_time + nursing_task_shift_factor*pharmacy_task_time) + + # Run healthsystemscheduler + healthsystemscheduler.apply(sim.population) + + # read the results + output = parse_log_file(sim.log_filepath, level=logging.DEBUG) + hs_output = output['tlo.methods.healthsystem']['HSI_Event'] + + # Check that all events could run, even if Pharmacy capabilities were set to zero, + # and that when task-shifting first option (nurses) where always preferentially chosen over second + # one (clinicians) + assert hs_output['did_run'].sum() == Ntarget + + @pytest.mark.slow def test_which_hsi_can_run(seed): """This test confirms whether, and how, HSI with each Appointment Type can run at each facility, under the From c8f70ca077236fa43e3c9926307ddc0f1a09daee Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Thu, 22 Feb 2024 10:53:28 +0000 Subject: [PATCH 2/8] Fix small bug introducing when fixing style --- tests/test_healthsystem.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_healthsystem.py b/tests/test_healthsystem.py index c0619f1680..798b5f8fa5 100644 --- a/tests/test_healthsystem.py +++ b/tests/test_healthsystem.py @@ -2021,9 +2021,7 @@ def apply(self, person_id, squeeze_factor): for i in range(0, int(tot_population)): sim.population.props.at[i, 'district_of_residence'] = keys_district[0] - - # Get facility ID - facID = int((re.search(r'\d+', next(iter(hsi1.expected_time_requests)))).group()) + # Schedule an identical appointment for all individuals for i in range(0, tot_population): @@ -2046,6 +2044,9 @@ def apply(self, person_id, squeeze_factor): appt_type='MinorSurg', level='1a') hsi1.initialise() + + # Get facility ID + facID = int((re.search(r'\d+', next(iter(hsi1.expected_time_requests)))).group()) pharmacy_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Pharmacy'] nursing_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] From d5f1de6ab8d86095f6e205fa2561278ea74ac891 Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Thu, 22 Feb 2024 13:58:30 +0000 Subject: [PATCH 3/8] isort error sorted --- tests/test_healthsystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_healthsystem.py b/tests/test_healthsystem.py index 798b5f8fa5..6692eeba43 100644 --- a/tests/test_healthsystem.py +++ b/tests/test_healthsystem.py @@ -1,12 +1,12 @@ import heapq as hp import os +import re from pathlib import Path from typing import Set, Tuple import numpy as np import pandas as pd import pytest -import re from tlo import Date, Module, Simulation, logging from tlo.analysis.hsi_events import get_details_of_defined_hsi_events From a2490a4ad7bbd2a46cf733a98c2a3e2b34f78d8b Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Wed, 28 Feb 2024 10:37:14 +0000 Subject: [PATCH 4/8] Expand test to include include_task_shifting=False case --- tests/test_healthsystem.py | 153 ++++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 70 deletions(-) diff --git a/tests/test_healthsystem.py b/tests/test_healthsystem.py index 6692eeba43..a5ca72b212 100644 --- a/tests/test_healthsystem.py +++ b/tests/test_healthsystem.py @@ -1993,89 +1993,102 @@ def apply(self, person_id, squeeze_factor): "directory": tmpdir, "custom_levels": {"tlo.methods.healthsystem": logging.DEBUG}, } - sim = Simulation(start_date=start_date, seed=seed, log_config=log_config) + + def simulate(task_shifting_option, Ntarget): + + sim = Simulation(start_date=start_date, seed=seed, log_config=log_config) - # Register the core modules and simulate for 0 days - sim.register(demography.Demography(resourcefilepath=resourcefilepath), - healthsystem.HealthSystem(resourcefilepath=resourcefilepath, - capabilities_coefficient=1.0, - mode_appt_constraints=2, - include_task_shifting=True, - ignore_priority=False, - randomise_queue=True, - policy_name="", - use_funded_or_actual_staffing='funded_plus'), - DummyModule() - ) + # Register the core modules and simulate for 0 days + sim.register(demography.Demography(resourcefilepath=resourcefilepath), + healthsystem.HealthSystem(resourcefilepath=resourcefilepath, + capabilities_coefficient=1.0, + mode_appt_constraints=2, + include_task_shifting=task_shifting_option, + ignore_priority=False, + randomise_queue=True, + policy_name="", + use_funded_or_actual_staffing='funded_plus'), + DummyModule() + ) - tot_population = 100 - sim.make_initial_population(n=tot_population) - sim.simulate(end_date=sim.start_date) + tot_population = 100 + sim.make_initial_population(n=tot_population) + sim.simulate(end_date=sim.start_date) - # Get pointer to the HealthSystemScheduler event - healthsystemscheduler = sim.modules['HealthSystem'].healthsystemscheduler + # Get pointer to the HealthSystemScheduler event + healthsystemscheduler = sim.modules['HealthSystem'].healthsystemscheduler - # Force entire population in one district (keys_district[0]) and get facility ID - person_for_district = {d: i for i, d in enumerate(sim.population.props['district_of_residence'].cat.categories)} - keys_district = list(person_for_district.keys()) + # Force entire population in one district (keys_district[0]) and get facility ID + person_for_district = {d: i for i, d in enumerate(sim.population.props['district_of_residence'].cat.categories)} + keys_district = list(person_for_district.keys()) - for i in range(0, int(tot_population)): - sim.population.props.at[i, 'district_of_residence'] = keys_district[0] + for i in range(0, int(tot_population)): + sim.population.props.at[i, 'district_of_residence'] = keys_district[0] - # Schedule an identical appointment for all individuals - for i in range(0, tot_population): + # Schedule an identical appointment for all individuals + for i in range(0, tot_population): - hsi = DummyHSIEvent(module=sim.modules['DummyModule'], - person_id=i, - appt_type='MinorSurg', - level='1a') + hsi = DummyHSIEvent(module=sim.modules['DummyModule'], + person_id=i, + appt_type='MinorSurg', + level='1a') - sim.modules['HealthSystem'].schedule_hsi_event( - hsi, - topen=sim.date, - tclose=sim.date + pd.DateOffset(days=1), - # Assign equal priority - priority=0 - ) + sim.modules['HealthSystem'].schedule_hsi_event( + hsi, + topen=sim.date, + tclose=sim.date + pd.DateOffset(days=1), + # Assign equal priority + priority=0 + ) - hsi1 = DummyHSIEvent(module=sim.modules['DummyModule'], - person_id=0, # Ensures call is on officers in first district - appt_type='MinorSurg', - level='1a') - hsi1.initialise() - - # Get facility ID - facID = int((re.search(r'\d+', next(iter(hsi1.expected_time_requests)))).group()) - - pharmacy_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Pharmacy'] - nursing_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] - clinical_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Clinical'] - - # Check that first choice of task-shifting officer for Pharmacy is Nursing_and_Midwifery, and get their factor - assert 'Nursing_and_Midwifery' == sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[0][0] - nursing_task_shift_factor = sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[1][0] - - # Number of appts that want to see delivered if pharmacy time is set to zero, - # clinical time is set to perform 50 appts, and nursing time is set to perform 50 appts including - # both nursing and pharmacy tasks. + hsi1 = DummyHSIEvent(module=sim.modules['DummyModule'], + person_id=0, # Ensures call is on officers in first district + appt_type='MinorSurg', + level='1a') + hsi1.initialise() + + # Get facility ID + facID = int((re.search(r'\d+', next(iter(hsi1.expected_time_requests)))).group()) + + pharmacy_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Pharmacy'] + nursing_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] + clinical_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Clinical'] + + # Check that first choice of task-shifting officer for Pharmacy is Nursing_and_Midwifery, and get their factor + assert 'Nursing_and_Midwifery' == sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[0][0] + nursing_task_shift_factor = sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[1][0] + + # Always set Pharmacy capabilities to zero. Set Clinical and Nursing capabilities such that Ntarget appointments + # can be delivered by relying on task-shifting from Nursing only. Setting Clinical time to Ntarget x clinical_task_time + # checks that when task-shifting first option (nurses) where always preferentially chosen over second. + # one (clinicians) + sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Pharmacy'] = 0.0 + sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Clinical'] = Ntarget*(clinical_task_time) + sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] = Ntarget*(nursing_task_time + nursing_task_shift_factor*pharmacy_task_time) + + # Run healthsystemscheduler + healthsystemscheduler.apply(sim.population) + + # read the results + output = parse_log_file(sim.log_filepath, level=logging.DEBUG) + hs_output = output['tlo.methods.healthsystem']['HSI_Event'] + + return hs_output + + # Pharmacy capabilities set to zero. Clinical and Nursing capabilities initialised such that Ntarget + # appointments can be performed iff using task-shifting Ntarget = 50 - sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Pharmacy'] = 0.0 - sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Clinical'] = Ntarget*(clinical_task_time) - sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] = Ntarget*(nursing_task_time + nursing_task_shift_factor*pharmacy_task_time) - - # Run healthsystemscheduler - healthsystemscheduler.apply(sim.population) - - # read the results - output = parse_log_file(sim.log_filepath, level=logging.DEBUG) - hs_output = output['tlo.methods.healthsystem']['HSI_Event'] - - # Check that all events could run, even if Pharmacy capabilities were set to zero, - # and that when task-shifting first option (nurses) where always preferentially chosen over second - # one (clinicians) + # Allow for task-shifting to take place, and check that all Ntarget events could run, even if Pharmacy + # capabilities were set to zero, + hs_output = simulate(task_shifting_option=True, Ntarget=Ntarget) assert hs_output['did_run'].sum() == Ntarget + + # Do not allow for task-shifting to take place, and check that as a result no events could run, since + # Pharmacy capabilities were set to zero. + hs_output = simulate(task_shifting_option=False, Ntarget=Ntarget) + assert hs_output['did_run'].sum() == 0 @pytest.mark.slow From 1dea0589ff68d6720cbc8ac13698f5a176701b25 Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Wed, 28 Feb 2024 17:06:55 +0000 Subject: [PATCH 5/8] Allow for multiple options in the GlobalTaskShifting resource file. This will be required for comparing scenarios --- .../ResourceFile_HealthSystem_parameters.csv | 4 +- src/tlo/methods/healthsystem.py | 76 ++++++++++++++----- tests/test_healthsystem.py | 25 +++--- 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv b/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv index 3144bd7380..5e21a9e66c 100644 --- a/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv +++ b/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cd5b508bfbb7b430cc05f116fd92df79e4663aff9d480e4ebd94dfa73530677e -size 420 +oid sha256:df273f1a413b6d6c2cc81f392aa62e00fea78f67631d2bc802a78e3474a7356d +size 426 diff --git a/src/tlo/methods/healthsystem.py b/src/tlo/methods/healthsystem.py index 3bcf4a0369..ea9c945102 100644 --- a/src/tlo/methods/healthsystem.py +++ b/src/tlo/methods/healthsystem.py @@ -564,9 +564,14 @@ class HealthSystem(Module): " the dynamic_HR_scaling_factor" ), - 'include_task_shifting': Parameter( - Types.BOOL, "Decide whether to allow for task-shifting in mode 2" - ), + 'global_task_shifting_mode': Parameter( + Types.STRING, "Name of global task-shifting mode adopted"), + + 'global_task_shifting': Parameter( + Types.DICT, "Global task-shifting policy adopted by the health care system. It includes a list of the officers" + " eligible for task shifting. For each of those officers, it specifies which officers" + " can take over the officer's tasks, and a factor explaining how the originally scheduled time " + " should be scaled if performed by a different officer."), 'tclose_overwrite': Parameter( Types.INT, "Decide whether to overwrite tclose variables assigned by disease modules"), @@ -603,7 +608,7 @@ def __init__( beds_availability: Optional[str] = None, randomise_queue: bool = True, ignore_priority: bool = False, - include_task_shifting: bool = False, + global_task_shifting_mode: Optional[str] = None, policy_name: Optional[str] = None, capabilities_coefficient: Optional[float] = None, use_funded_or_actual_staffing: Optional[str] = None, @@ -665,14 +670,6 @@ def __init__( assert not (ignore_priority and policy_name is not None), ( 'Cannot adopt a priority policy if the priority will be then ignored' ) - - # Global task-shifting options. The key in the dictionary refers to the officer - # eligible for task shifting, while the values refer to the officers that can take - # over the officer's tasks. The numbers refer to the factor by which appt time will - # have to be scaled if task is performed by alternative officer. - self.global_task_shifting = { - 'Pharmacy': (['Nursing_and_Midwifery', 'Clinical'], [1.5,1]), - } self.disable = disable self.disable_and_reject_all = disable_and_reject_all @@ -689,8 +686,6 @@ def __init__( self.ignore_priority = ignore_priority - self.include_task_shifting = include_task_shifting - # This default value will be overwritten if assumed policy is not None self.lowest_priority_considered = 2 @@ -701,6 +696,8 @@ def __init__( 'VerticalProgrammes', 'ClinicallyVulnerable', 'EHP_III', 'LCOA_EHP'] self.arg_policy_name = policy_name + + self.arg_global_task_shifting_mode = global_task_shifting_mode self.tclose_overwrite = None self.tclose_days_offset_overwrite = None @@ -833,6 +830,11 @@ def read_parameters(self, data_folder): self.parameters['priority_rank'] = pd.read_excel(path_to_resourcefiles_for_healthsystem / 'priority_policies' / 'ResourceFile_PriorityRanking_ALLPOLICIES.xlsx', sheet_name=None) + + self.parameters['global_task_shifting'] = pd.read_excel(path_to_resourcefiles_for_healthsystem / 'human_resources'/ + 'task_shifting'/'ResourceFile_GlobalTaskShifting.xlsx', + sheet_name=None) + self.parameters['const_HR_scaling_table']: Dict = pd.read_excel( path_to_resourcefiles_for_healthsystem / @@ -887,6 +889,9 @@ def pre_initialise_population(self): # Set up framework for considering a priority policy self.setup_priority_policy() + + # Set up framework for considering a global task-shifting policy + self.setup_global_task_shifting(self.parameters['global_task_shifting_mode']) def initialise_population(self, population): @@ -988,6 +993,37 @@ def setup_priority_policy(self): self.list_fasttrack.append(('hv_diagnosed', 'FT_if_Hivdiagnosed')) if 'Tb' in self.sim.modules: self.list_fasttrack.append(('tb_diagnosed', 'FT_if_tbdiagnosed')) + + def setup_global_task_shifting(self,mode): + + # Select the global task shifting mode to be considered + self.global_task_shifting_mode = self.get_global_task_shifting_mode_initial() + + # Load relevant sheet from resource file + df = self.parameters['global_task_shifting'][self.global_task_shifting_mode] + + self.global_task_shifting = {} + + # Iterate through the rows of the DataFrame and populate the dictionary + for index, row in df.iterrows(): + officer = row['Officer'] + alternative_officers = row['Alternative_officer'].split(',') + time_factor_str = str(row['Time_factor']) + + # Split the string into a list of floats + time_factors = [float(factor) for factor in time_factor_str.split(',')] + + # Create the entry in the dictionary + self.global_task_shifting[officer] = (alternative_officers, time_factors) + + if len(self.global_task_shifting) == 0: + self.include_task_shifting = False + else: + self.include_task_shifting = True + + # Print the resulting dictionary + print("self.global_task_shifting =", self.global_task_shifting) + def process_human_resources_files(self, use_funded_or_actual_staffing: str): """Create the data-structures needed from the information read into the parameters.""" @@ -1324,6 +1360,14 @@ def get_priority_policy_initial(self) -> str: return self.parameters['policy_name'] \ if self.arg_policy_name is None \ else self.arg_policy_name + + def get_global_task_shifting_mode_initial(self) -> str: + """Returns `priority_policy`. (Should be equal to what is specified by the parameter, but + overwrite with what was provided in argument if an argument was specified -- provided for backward + compatibility/debugging.)""" + return self.parameters['global_task_shifting_mode'] \ + if self.arg_global_task_shifting_mode is None \ + else self.arg_global_task_shifting_mode def load_priority_policy(self, policy): @@ -2922,7 +2966,6 @@ class HealthSystemChangeParameters(Event, PopulationScopeEventMixin): """Event that causes certain internal parameters of the HealthSystem to be changed; specifically: * `mode_appt_constraints` * `ignore_priority` - * `include_task_shifting` * `capabilities_coefficient` * `cons_availability` * `beds_availability` @@ -2939,9 +2982,6 @@ def apply(self, population): if 'ignore_priority' in self._parameters: self.module.ignore_priority = self._parameters['ignore_priority'] - - if 'include_task_shifting' in self._parameters: - self.module.include_task_shifting = self._parameters['include_task_shifting'] if 'capabilities_coefficient' in self._parameters: self.module.capabilities_coefficient = self._parameters['capabilities_coefficient'] diff --git a/tests/test_healthsystem.py b/tests/test_healthsystem.py index a5ca72b212..1adde7d3a1 100644 --- a/tests/test_healthsystem.py +++ b/tests/test_healthsystem.py @@ -1258,7 +1258,6 @@ def test_HealthSystemChangeParameters(seed, tmpdir): initial_parameters = { 'mode_appt_constraints': 0, 'ignore_priority': False, - 'include_task_shifting': False, 'capabilities_coefficient': 0.5, 'cons_availability': 'all', 'beds_availability': 'default', @@ -1266,7 +1265,6 @@ def test_HealthSystemChangeParameters(seed, tmpdir): new_parameters = { 'mode_appt_constraints': 2, 'ignore_priority': True, - 'include_task_shifting': True, 'capabilities_coefficient': 1.0, 'cons_availability': 'none', 'beds_availability': 'none', @@ -1282,7 +1280,6 @@ def apply(self, population): _params = dict() _params['mode_appt_constraints'] = hs.mode_appt_constraints _params['ignore_priority'] = hs.ignore_priority - _params['include_task_shifting'] = hs.include_task_shifting _params['capabilities_coefficient'] = hs.capabilities_coefficient _params['cons_availability'] = hs.consumables.cons_availability _params['beds_availability'] = hs.bed_days.availability @@ -2003,7 +2000,7 @@ def simulate(task_shifting_option, Ntarget): healthsystem.HealthSystem(resourcefilepath=resourcefilepath, capabilities_coefficient=1.0, mode_appt_constraints=2, - include_task_shifting=task_shifting_option, + global_task_shifting_mode=task_shifting_option, ignore_priority=False, randomise_queue=True, policy_name="", @@ -2055,14 +2052,16 @@ def simulate(task_shifting_option, Ntarget): nursing_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] clinical_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Clinical'] - # Check that first choice of task-shifting officer for Pharmacy is Nursing_and_Midwifery, and get their factor - assert 'Nursing_and_Midwifery' == sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[0][0] - nursing_task_shift_factor = sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[1][0] + if task_shifting_option == 'default': + nursing_task_shift_factor = 0 + else: + assert 'Nursing_and_Midwifery' == sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[0][0] + nursing_task_shift_factor = sim.modules['HealthSystem'].global_task_shifting.get('Pharmacy')[1][0] # Always set Pharmacy capabilities to zero. Set Clinical and Nursing capabilities such that Ntarget appointments # can be delivered by relying on task-shifting from Nursing only. Setting Clinical time to Ntarget x clinical_task_time # checks that when task-shifting first option (nurses) where always preferentially chosen over second. - # one (clinicians) + # one (clinicians) sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Pharmacy'] = 0.0 sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Clinical'] = Ntarget*(clinical_task_time) sim.modules['HealthSystem']._daily_capabilities['FacilityID_' + str(facID) + '_Officer_Nursing_and_Midwifery'] = Ntarget*(nursing_task_time + nursing_task_shift_factor*pharmacy_task_time) @@ -2080,14 +2079,14 @@ def simulate(task_shifting_option, Ntarget): # appointments can be performed iff using task-shifting Ntarget = 50 - # Allow for task-shifting to take place, and check that all Ntarget events could run, even if Pharmacy + # Allow for task-shifting to take place (by adopting the naive mode), and check that all Ntarget events could run, even if Pharmacy # capabilities were set to zero, - hs_output = simulate(task_shifting_option=True, Ntarget=Ntarget) + hs_output = simulate(task_shifting_option='naive', Ntarget=Ntarget) assert hs_output['did_run'].sum() == Ntarget - # Do not allow for task-shifting to take place, and check that as a result no events could run, since - # Pharmacy capabilities were set to zero. - hs_output = simulate(task_shifting_option=False, Ntarget=Ntarget) + # Do not allow for task-shifting to take place (by adopting the default mode), and check that as a + # result no events could run, since Pharmacy capabilities were set to zero. + hs_output = simulate(task_shifting_option='default', Ntarget=Ntarget) assert hs_output['did_run'].sum() == 0 From 6445c9b47559abe59628a927df7c1c749608c51a Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Wed, 28 Feb 2024 17:38:49 +0000 Subject: [PATCH 6/8] Artificial commit to check why tests are failing online --- src/tlo/methods/healthsystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tlo/methods/healthsystem.py b/src/tlo/methods/healthsystem.py index ea9c945102..728ab67407 100644 --- a/src/tlo/methods/healthsystem.py +++ b/src/tlo/methods/healthsystem.py @@ -568,7 +568,7 @@ class HealthSystem(Module): Types.STRING, "Name of global task-shifting mode adopted"), 'global_task_shifting': Parameter( - Types.DICT, "Global task-shifting policy adopted by the health care system. It includes a list of the officers" + Types.DICT, " Global task-shifting policy adopted by the health care system. It includes a list of the officers" " eligible for task shifting. For each of those officers, it specifies which officers" " can take over the officer's tasks, and a factor explaining how the originally scheduled time " " should be scaled if performed by a different officer."), From 6e8357ba0b63bac7bc92d9a0f5c6c3b5f2078a2f Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Tue, 5 Mar 2024 12:13:51 +0000 Subject: [PATCH 7/8] Inflate _appt_times to include all possible appt_footprints including task-shifting --- .../ResourceFile_HealthSystem_parameters.csv | 4 +- src/tlo/methods/healthsystem.py | 222 +++++++++++++----- tests/test_healthsystem.py | 1 - 3 files changed, 169 insertions(+), 58 deletions(-) diff --git a/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv b/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv index 5e21a9e66c..2d73d5c789 100644 --- a/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv +++ b/resources/healthsystem/ResourceFile_HealthSystem_parameters.csv @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df273f1a413b6d6c2cc81f392aa62e00fea78f67631d2bc802a78e3474a7356d -size 426 +oid sha256:a2e9d24736e254c3eabba58e83612c8f7615d65e52402b42c07b3e7f6b26e85d +size 424 diff --git a/src/tlo/methods/healthsystem.py b/src/tlo/methods/healthsystem.py index 728ab67407..f79366214e 100644 --- a/src/tlo/methods/healthsystem.py +++ b/src/tlo/methods/healthsystem.py @@ -1,10 +1,11 @@ import datetime import heapq as hp import itertools +import re import warnings from collections import Counter, defaultdict from collections.abc import Iterable -from itertools import repeat +from itertools import combinations, product, repeat from pathlib import Path from typing import Dict, List, NamedTuple, Optional, Tuple, Union @@ -635,8 +636,6 @@ def __init__( and priority :param ignore_priority: If ``True`` do not use the priority information in HSI event to schedule - :param include_task_shifting: If ``True`` when in mode 2 consider task-shifting of officers if one originally - required is not available :param policy_name: Name of priority policy that will be adopted if any :param capabilities_coefficient: Multiplier for the capabilities of health officers, if ``None`` set to ratio of initial population to estimated 2010 @@ -891,7 +890,7 @@ def pre_initialise_population(self): self.setup_priority_policy() # Set up framework for considering a global task-shifting policy - self.setup_global_task_shifting(self.parameters['global_task_shifting_mode']) + self.setup_global_task_shifting() def initialise_population(self, population): @@ -994,7 +993,7 @@ def setup_priority_policy(self): if 'Tb' in self.sim.modules: self.list_fasttrack.append(('tb_diagnosed', 'FT_if_tbdiagnosed')) - def setup_global_task_shifting(self,mode): + def setup_global_task_shifting(self): # Select the global task shifting mode to be considered self.global_task_shifting_mode = self.get_global_task_shifting_mode_initial() @@ -1015,15 +1014,137 @@ def setup_global_task_shifting(self,mode): # Create the entry in the dictionary self.global_task_shifting[officer] = (alternative_officers, time_factors) + + # If task-shifting is considered, expand the number of possible appt_footprints + # to include potential task-shiftin + if self.global_task_shifting: - if len(self.global_task_shifting) == 0: - self.include_task_shifting = False - else: - self.include_task_shifting = True + _appt_times_expand = {_facility_level: defaultdict(list) for _facility_level in + self._facility_levels} + for level in self._facility_levels: + + + + for appt_footprint in self._appt_times[level]: + # Get all officers required for this appointment + officers_required = [subunit.officer_type for subunit in self._appt_times[level][appt_footprint]] + # Among these, select those that could be eligible for task-shifting if need should arise + officers_eligible_for_ts = [item for item in officers_required if item in self.global_task_shifting] + + tags_dictionary = {} + for officer in officers_eligible_for_ts: + tags = [] + # Find all potential task-shifting options for this officer + for officer_ts in self.global_task_shifting[officer][0]: + tags.append(officer[:4] + "-" + officer_ts[:4]) + tags_dictionary[officer] = tags + + # Calculate all possible appt footprints that may result from task-shifting + new_footprints = [] + for r in range(1, len(officers_eligible_for_ts)+1): + # Each combination illustrates potentially unavailable officers + for combination in combinations(officers_eligible_for_ts, r): + #product_options = product(*(map(str, tags_dictionary[key]) for key in combination)) + #result_strings = [appt_footprint + '_withTS_' + '_and_'.join(option) for option in product_options#] + #new_footprints.extend(result_strings) + + product_options = product(*(map(str, tags_dictionary[key]) for key in combination)) + + # Sort the individual substrings alphabetically + sorted_substrings = sorted('_and_'.join(option) for option in product_options) + + # Create result strings and add to new_footprints + result_strings = [appt_footprint + '_withTS_' + option for option in sorted_substrings] + new_footprints.extend(result_strings) + + # Add all these new footprints to the table and correct times required + + # Initially assume this original call. This will have to be updated as + # multiple officers may potentially be replaced. + original_call = self._appt_times[level][appt_footprint] + + # Task-shifting is logged in appt_footprint name as "xxxx-yyyy", where xxxx + # are the first four letters of officer-type being task-shifted, and yyyy + # are the first four letters of officer-type taking over tasks + pattern = re.compile(r'(?<=[-_])(\w{4}-\w{4})') + + # Get the officers and times associated with the original footprint + appt_footprint_times = Counter() + appt_info_list = self._appt_times[level][appt_footprint] + for appt_info in appt_info_list: + appt_footprint_times[ + f"{appt_info.officer_type}" + ] += appt_info.time_taken + + + for new_footprint in new_footprints: + + # Get all instances of task-shifting taking place in this appt footprint + task_shifting_adopted = {} + + # Find all instances of task-shifting in string + matches = pattern.findall(new_footprint) + + for match in matches: + short_original_officer = match[:4] + short_new_officer = match[5:] + + # Get the full name of the original medical officer + original_officer = (next((subunit for subunit in original_call if subunit.officer_type.startswith(short_original_officer)), None)).officer_type + + officer_types, factors = self.global_task_shifting[original_officer] + + # Iterate through officer_types and find the appropriate time factor + # linked to this task-shifting + for i, officer_type in enumerate(officer_types): + if officer_type.startswith(short_new_officer): + new_officer = officer_type + factor = factors[i] + break # Stop iterating once a match is found + + # Add this task shifting and factor by which will scale to dictionary + task_shifting_adopted[original_officer] = (new_officer,factor) + + if task_shifting_adopted: + updated_call_inc_task_shift = {} + # Go over all officers in updated_call + for k in appt_footprint_times.keys(): + + # If task-shifting was requested for this officer, change name + # of officer and rescale original task by relevant factor + if k in task_shifting_adopted.keys(): + + task_for_officer = appt_footprint_times[k]*task_shifting_adopted[k][1] + j = task_shifting_adopted[k][0] + + if j in updated_call_inc_task_shift.keys(): + # If officer is already included in updated_call_inc_task_shift + # (e.g. because it was already performing own tasks as well as + # taking over that of officer not available) add to original task + updated_call_inc_task_shift[j] += task_for_officer + else: + updated_call_inc_task_shift[j] = task_for_officer + + # Else simply add original requirement to new call + else: + if k in updated_call_inc_task_shift.keys(): + # Ensure that if this officer already present in dictionary + # this task is added, not overwritten + updated_call_inc_task_shift[k] += appt_footprint_times[k] + else: + updated_call_inc_task_shift[k] = appt_footprint_times[k] + + for k in updated_call_inc_task_shift.keys(): + _appt_times_expand[level][new_footprint].append( + AppointmentSubunit( + officer_type=k, + time_taken= updated_call_inc_task_shift[k] + ) + ) + + # Add new footprints to the original dictionary at this level + self._appt_times[level].update(_appt_times_expand[level]) - # Print the resulting dictionary - print("self.global_task_shifting =", self.global_task_shifting) - def process_human_resources_files(self, use_funded_or_actual_staffing: str): """Create the data-structures needed from the information read into the parameters.""" @@ -1063,6 +1184,9 @@ def process_human_resources_files(self, use_funded_or_actual_staffing: str): ) == len(appt_time_data) ) self._appt_times = appt_times_per_level_and_type + + + # * Define Which Appointments Are Possible At Each Facility Level appt_type_per_level_data = self.parameters['Appt_Offered_By_Facility_Level'] @@ -1716,6 +1840,7 @@ def get_appt_footprint_as_time_request(self, facility_info: FacilityInfo, appt_f :return: A Counter that gives the times required for each officer-type in each facility_ID, where this time is non-zero. """ + # Accumulate appointment times for specified footprint using times from appointment times table. appt_footprint_times = Counter() for appt_type in appt_footprint: @@ -2502,6 +2627,7 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: # Retrieve officers&facility required for HSI original_call = next_event_tuple.hsi_event.expected_time_requests _priority = next_event_tuple.priority + # In this version of mode_appt_constraints = 2, do not have access to squeeze # based on queue information, and we assume no squeeze ever takes place. squeeze_factor = 0. @@ -2511,8 +2637,9 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: # that are no longer available. out_of_resources = False - # This dictionary stores all task-shifting officers considered for this appointment - task_shifting_adopted = {} + # This string will store all TS taking place + task_shifting_tag = "" + for officer, call in original_call.items(): # If any of the officers are not available, then out of resources, unless these can be # task-shifted @@ -2525,7 +2652,7 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: # then will reset to False out_of_resources = True - if self.sim.modules['HealthSystem'].include_task_shifting: + if len(self.sim.modules['HealthSystem'].global_task_shifting) > 0: # Get officer type only officer_no_facility = officer.split("Officer_")[1] @@ -2547,13 +2674,16 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: # Check if this task-shifting officer is available, and save task_shift if shift_target_officer in set_capabilities_still_available: # Record task-shifting - task_shifting_adopted[officer] = (shift_target_officer, time_scaling) + if task_shifting_tag == "": + task_shifting_tag += "_withTS_" + officer_no_facility[:4] + "-" + new_officer_no_facility[:4] + "_and_" + else: + task_shifting_tag += officer_no_facility[:4] + "-" + new_officer_no_facility[:4] + "_and_" out_of_resources = False # Once we've found available officer to replace, no need to go through other # options break - + # If officers still available, run event. Note: in current logic, a little # overtime is allowed to run last event of the day. This seems more realistic # than medical staff leaving earlier than @@ -2612,51 +2742,35 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: # check its formatting: assert self.module.appt_footprint_is_valid(actual_appt_footprint) + # Add task-shifting tag when recalculating updated_call if not empty. + if task_shifting_tag != "": + if task_shifting_tag.endswith('_and_'): + task_shifting_tag = task_shifting_tag[:-5] + actual_appt_footprint = Counter({item + task_shifting_tag: count for item, count in actual_appt_footprint.items()}) + # Update call that will be used to compute capabilities used updated_call = self.module.get_appt_footprint_as_time_request( facility_info=event.facility_info, appt_footprint=actual_appt_footprint ) else: - actual_appt_footprint = _appt_footprint_before_running - updated_call = original_call + # If no task-shifting was required, go ahead as usual + if task_shifting_tag == "": + actual_appt_footprint = _appt_footprint_before_running + updated_call = original_call + # Else need to update footprint and recalculate call + else: + if task_shifting_tag.endswith('_and_'): + task_shifting_tag = task_shifting_tag[:-5] + actual_appt_footprint= Counter({item + task_shifting_tag: count for item, count in _appt_footprint_before_running.items()}) + updated_call = self.module.get_appt_footprint_as_time_request( + facility_info=event.facility_info, + appt_footprint=actual_appt_footprint + ) # Recalculate call on officers based on squeeze factor. for k in updated_call.keys(): updated_call[k] = updated_call[k]/(squeeze_factor + 1.) - - # Recalculate call on officers including task shifting, which may result in - # a change to required time - if task_shifting_adopted: - updated_call_inc_task_shift = {} - # Go over all officers in updated_call - for k in updated_call.keys(): - - # If task-shifting was requested for this officer, change name - # of officer and rescale original task by relevant factor - if k in task_shifting_adopted.keys(): - - task_for_officer = updated_call[k]*task_shifting_adopted[k][1] - j = task_shifting_adopted[k][0] - - if j in updated_call_inc_task_shift.keys(): - # If officer is already included in updated_call_inc_task_shift - # (e.g. because it was already performing own tasks as well as - # taking over that of officer not available) add to original task - updated_call_inc_task_shift[j] += task_for_officer - else: - updated_call_inc_task_shift[j] = task_for_officer - - # Else simply add original requirement to new call - else: - if k in updated_call_inc_task_shift.keys(): - # Ensure that if this officer already present in dictionary - # this task is added, not overwritten - updated_call_inc_task_shift[k] += updated_call[k] - else: - updated_call_inc_task_shift[k] = updated_call[k] - - updated_call = updated_call_inc_task_shift # Subtract this from capabilities used so-far today capabilities_monitor.subtract(updated_call) @@ -2678,9 +2792,7 @@ def process_events_mode_2(self, hold_over: List[HSIEventQueueItem]) -> None: self.module.running_total_footprint -= original_call self.module.running_total_footprint += updated_call - # Write to the log - # WARNING: the logged appt footprint does not contain information - # on whether task-shifting was performed or not. + # Write to the log. Appt footprint now includes info on task-shifting self.module.record_hsi_event( hsi_event=event, actual_appt_footprint=actual_appt_footprint, diff --git a/tests/test_healthsystem.py b/tests/test_healthsystem.py index 1adde7d3a1..2a423c8cbc 100644 --- a/tests/test_healthsystem.py +++ b/tests/test_healthsystem.py @@ -2045,7 +2045,6 @@ def simulate(task_shifting_option, Ntarget): level='1a') hsi1.initialise() - # Get facility ID facID = int((re.search(r'\d+', next(iter(hsi1.expected_time_requests)))).group()) pharmacy_task_time = hsi1.expected_time_requests['FacilityID_' + str(facID) + '_Officer_Pharmacy'] From 77963be43031656039a228c755d1671bc53f658b Mon Sep 17 00:00:00 2001 From: Margherita Molaro <48129834+marghe-molaro@users.noreply.github.com> Date: Tue, 5 Mar 2024 18:08:01 +0000 Subject: [PATCH 8/8] Minor style fixes --- src/tlo/methods/healthsystem.py | 46 ++++++++++++--------------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/src/tlo/methods/healthsystem.py b/src/tlo/methods/healthsystem.py index f79366214e..3ce2e43159 100644 --- a/src/tlo/methods/healthsystem.py +++ b/src/tlo/methods/healthsystem.py @@ -1016,15 +1016,14 @@ def setup_global_task_shifting(self): self.global_task_shifting[officer] = (alternative_officers, time_factors) # If task-shifting is considered, expand the number of possible appt_footprints - # to include potential task-shiftin + # to include potential task-shifting if self.global_task_shifting: _appt_times_expand = {_facility_level: defaultdict(list) for _facility_level in self._facility_levels} + for level in self._facility_levels: - - for appt_footprint in self._appt_times[level]: # Get all officers required for this appointment officers_required = [subunit.officer_type for subunit in self._appt_times[level][appt_footprint]] @@ -1039,47 +1038,36 @@ def setup_global_task_shifting(self): tags.append(officer[:4] + "-" + officer_ts[:4]) tags_dictionary[officer] = tags - # Calculate all possible appt footprints that may result from task-shifting + # Calculate all possible appt footprints that may result from task-shifting, accounting + # for the fact that more than one requested officer may be eligible for task-shifting. new_footprints = [] for r in range(1, len(officers_eligible_for_ts)+1): # Each combination illustrates potentially unavailable officers for combination in combinations(officers_eligible_for_ts, r): - #product_options = product(*(map(str, tags_dictionary[key]) for key in combination)) - #result_strings = [appt_footprint + '_withTS_' + '_and_'.join(option) for option in product_options#] - #new_footprints.extend(result_strings) - product_options = product(*(map(str, tags_dictionary[key]) for key in combination)) - - # Sort the individual substrings alphabetically - sorted_substrings = sorted('_and_'.join(option) for option in product_options) - - # Create result strings and add to new_footprints - result_strings = [appt_footprint + '_withTS_' + option for option in sorted_substrings] + result_strings = [appt_footprint + '_withTS_' + '_and_'.join(option) for option in product_options] new_footprints.extend(result_strings) - # Add all these new footprints to the table and correct times required - - # Initially assume this original call. This will have to be updated as - # multiple officers may potentially be replaced. - original_call = self._appt_times[level][appt_footprint] + # For all these new footprints now obtain an updated call. # Task-shifting is logged in appt_footprint name as "xxxx-yyyy", where xxxx # are the first four letters of officer-type being task-shifted, and yyyy # are the first four letters of officer-type taking over tasks pattern = re.compile(r'(?<=[-_])(\w{4}-\w{4})') - + + # Initially assume this original call. This will be updated + # depending on task-shifting considered. + original_call = self._appt_times[level][appt_footprint] # Get the officers and times associated with the original footprint appt_footprint_times = Counter() - appt_info_list = self._appt_times[level][appt_footprint] - for appt_info in appt_info_list: + for appt_info in original_call: appt_footprint_times[ f"{appt_info.officer_type}" ] += appt_info.time_taken - for new_footprint in new_footprints: - # Get all instances of task-shifting taking place in this appt footprint + # Store all instances of task-shifting taking place in this appt footprint task_shifting_adopted = {} # Find all instances of task-shifting in string @@ -1091,11 +1079,11 @@ def setup_global_task_shifting(self): # Get the full name of the original medical officer original_officer = (next((subunit for subunit in original_call if subunit.officer_type.startswith(short_original_officer)), None)).officer_type - - officer_types, factors = self.global_task_shifting[original_officer] - # Iterate through officer_types and find the appropriate time factor + # Iterate through officers eligible for task shiting and find the appropriate time factor # linked to this task-shifting + officer_types, factors = self.global_task_shifting[original_officer] + for i, officer_type in enumerate(officer_types): if officer_type.startswith(short_new_officer): new_officer = officer_type @@ -1107,7 +1095,7 @@ def setup_global_task_shifting(self): if task_shifting_adopted: updated_call_inc_task_shift = {} - # Go over all officers in updated_call + # Go over all officers required for this appointment for k in appt_footprint_times.keys(): # If task-shifting was requested for this officer, change name @@ -1149,8 +1137,6 @@ def setup_global_task_shifting(self): def process_human_resources_files(self, use_funded_or_actual_staffing: str): """Create the data-structures needed from the information read into the parameters.""" - - # * Define Facility Levels self._facility_levels = set(self.parameters['Master_Facilities_List']['Facility_Level']) - {'5'} assert self._facility_levels == {'0', '1a', '1b', '2', '3', '4'} # todo soft code this?