Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include task-shifting #1280

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Git LFS file not shown
109 changes: 104 additions & 5 deletions src/tlo/methods/healthsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -599,6 +603,7 @@ def __init__(
beds_availability: Optional[str] = None,
randomise_queue: bool = True,
ignore_priority: bool = False,
include_task_shifting: bool = False,
Copy link
Collaborator

Choose a reason for hiding this comment

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

why a module keyword argument and a module Parameter? I think best to stick to it being ONLY a Parameter, if possible.

policy_name: Optional[str] = None,
capabilities_coefficient: Optional[float] = None,
use_funded_or_actual_staffing: Optional[str] = None,
Expand All @@ -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
Expand Down Expand Up @@ -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]),
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think more pythonic to write as we don;t then rely on ordering of these two lists, and the relationship between the numbers (1.5, 1.0) and strings ('Nursing', 'Clinical') is made explicit. (Also when it's used you're using zip to iterate through these things together, but if it were a dict, you could just use .items())

Suggested change
self.global_task_shifting = {
'Pharmacy': (['Nursing_and_Midwifery', 'Clinical'], [1.5,1]),
}
self.global_task_shifting = {
'Pharmacy': {'Nursing_and_Midwifery': 1.5, 'Clinical': 1.0},
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also - shouldn't think be read in from a .csv ResourceFile, so that we can edit this in different simulations?

Structure of .csv file:

Nursing_and_Midwifery,1.5
Clinical,1.0

This would actually made the Parameter (include_task_shifting) redundant, because if we didn't want to allow any (global) task shifting rules, then this file would be blank.


self.disable = disable
self.disable_and_reject_all = disable_and_reject_all
Expand 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
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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 = {}
Copy link
Collaborator

Choose a reason for hiding this comment

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

this dict isn't logged or used.

To allow logging, we could save a flag onto the HSI Event itself (e.g. .global_task_shifting_used: bool) and then use this property when do the logging (summary and detail).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It is adopted, and it must be used to know which officers were assigned what tasks (line 2506)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see it being written to but not read from (?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Lines 2593 to 2596

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:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if self.sim.modules['HealthSystem'].include_task_shifting:
if self.module.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
Comment on lines +2653 to +2654
Copy link
Collaborator

Choose a reason for hiding this comment

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

why unpack only to zip in the following line? I think we can skip this line.


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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

As noted above, this dict of record isn't used anywhere.

out_of_resources = False

# Once we've found available officer to replace, no need to go through other
# options
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# options
# options for other officers to task-shift to.

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,
Expand Down Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

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

this could be accomplished by saving the flag onto the HSI Event as per above?

_appt_footprint_before_running = event.EXPECTED_APPT_FOOTPRINT
# Run event & get actual footprint
actual_appt_footprint = event.run(squeeze_factor=squeeze_factor)
Expand All @@ -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)

Expand All @@ -2543,13 +2635,16 @@ 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.
Copy link
Collaborator

Choose a reason for hiding this comment

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

indeed, this would be another reason to let the HSI return actual footprint that its provided with

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

See reply to comment above

self.module.record_hsi_event(
hsi_event=event,
actual_appt_footprint=actual_appt_footprint,
squeeze_factor=squeeze_factor,
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.
Expand Down Expand Up @@ -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`
Copy link
Collaborator

Choose a reason for hiding this comment

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

as above, it seems duplicative to me to provide two ways to modify this behaviour. I think it should just be through Parameters.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes you are right, the reason this wasn't optimised at this stage is that we're still not sure whether we wanted to allow for a global and HSI-specific task-shifting approach (in which case e.g. include_task_shifting could be categorical - None, Global-level, HSI-level and we would have an additional resource file in case of global taskshifting, one for HSI-based on etc)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's always the case that we should modify the behaviour of the module through Parametees rather than kwargs though, as that it has the Scenario class expects to pass through changes.

* `capabilities_coefficient`
* `cons_availability`
* `beds_availability`
Expand All @@ -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']
Expand Down
126 changes: 126 additions & 0 deletions tests/test_healthsystem.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import heapq as hp
import os
import re
from pathlib import Path
from typing import Set, Tuple

Expand Down Expand Up @@ -1257,13 +1258,15 @@ 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',
}
new_parameters = {
'mode_appt_constraints': 2,
'ignore_priority': True,
'include_task_shifting': True,
'capabilities_coefficient': 1.0,
'cons_availability': 'none',
'beds_availability': 'none',
Expand All @@ -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
Expand Down Expand Up @@ -1952,6 +1956,128 @@ 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]


# 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()

# 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.
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

Copy link
Collaborator

Choose a reason for hiding this comment

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

To strengthen the test and see the impact of task-shifting versus no task-shifting, I think it would be good to repeat this for with/without global task-shifting so we can see the difference.


@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
Expand Down