-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathhealthsystem.py
2840 lines (2356 loc) · 143 KB
/
healthsystem.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import datetime
import heapq as hp
import itertools
import warnings
from collections import Counter, defaultdict
from collections.abc import Iterable
from itertools import repeat
from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Tuple, Union
import numpy as np
import pandas as pd
from pandas.testing import assert_series_equal
import tlo
from tlo import Date, DateOffset, Module, Parameter, Property, Types, logging
from tlo.analysis.utils import ( # get_filtered_treatment_ids,
flatten_multi_index_series_into_dict_for_logging,
)
from tlo.events import Event, PopulationScopeEventMixin, Priority, RegularEvent
from tlo.methods import Metadata
from tlo.methods.bed_days import BedDays
from tlo.methods.consumables import (
Consumables,
get_item_code_from_item_name,
get_item_codes_from_package_name,
)
from tlo.methods.dxmanager import DxManager
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger_summary = logging.getLogger(f"{__name__}.summary")
logger_summary.setLevel(logging.INFO)
# Declare the level which will be used to represent the merging of levels '1b' and '2'
LABEL_FOR_MERGED_FACILITY_LEVELS_1B_AND_2 = '2'
# Declare the assumption for the availability of consumables at the merged levels '1b' and '2'. This can be a
# list of facility_levels over which an average is taken (within a district): e.g. ['1b', '2'].
AVAILABILITY_OF_CONSUMABLES_AT_MERGED_LEVELS_1B_AND_2 = ['1b'] # <-- Implies that availability at merged level '1b & 2'
# is equal to availability at level '1b'. This is
# reasonable because the '1b' are more numerous than
# those of '2' and have more overall capacity, so
# probably account for the majority of the
# interactions.
def adjust_facility_level_to_merge_1b_and_2(level: str) -> str:
"""Adjust the facility level of an HSI_Event so that HSI_Events scheduled at level '1b' and '2' are both directed
to level '2'"""
return level if level not in ('1b', '2') else LABEL_FOR_MERGED_FACILITY_LEVELS_1B_AND_2
def pool_capabilities_at_levels_1b_and_2(df_original: pd.DataFrame) -> pd.DataFrame:
"""Return a modified version of the imported capabilities DataFrame to reflect that the capabilities of level 1b
are pooled with those of level 2, and all labelled as level 2."""
# Find total minutes and staff count after the re-allocation of capabilities from '1b' to '2'
tots_after_reallocation = df_original \
.assign(Facility_Level=lambda df: df.Facility_Level.replace({
'1b': LABEL_FOR_MERGED_FACILITY_LEVELS_1B_AND_2,
'2': LABEL_FOR_MERGED_FACILITY_LEVELS_1B_AND_2})
) \
.groupby(by=['Facility_Level', 'District', 'Region', 'Officer_Category'], dropna=False)[[
'Total_Mins_Per_Day', 'Staff_Count']] \
.sum() \
.reset_index()
# Construct a new version of the dataframe that uses the new totals
df_updated = df_original \
.drop(columns=['Total_Mins_Per_Day', 'Staff_Count'])\
.merge(tots_after_reallocation,
on=['Facility_Level', 'District', 'Region', 'Officer_Category'],
how='left',
) \
.assign(
Total_Mins_Per_Day=lambda df: df.Total_Mins_Per_Day.fillna(0.0),
Staff_Count=lambda df: df.Staff_Count.fillna(0.0)
)
# Check that the *total* number of minutes per officer in each district/region is the same as before the change
assert_series_equal(
df_updated.groupby(by=['District', 'Region', 'Officer_Category'], dropna=False)['Total_Mins_Per_Day'].sum(),
df_original.groupby(by=['District', 'Region', 'Officer_Category'], dropna=False)['Total_Mins_Per_Day'].sum()
)
df_updated.groupby('Facility_Level')['Total_Mins_Per_Day'].sum()
# Check size/shape of the updated dataframe is as expected
assert df_updated.shape == df_original.shape
assert (df_updated.dtypes == df_original.dtypes).all()
for _level in ['0', '1a', '3', '4']:
assert df_original.loc[df_original.Facility_Level == _level].equals(
df_updated.loc[df_updated.Facility_Level == _level])
assert np.isclose(
df_updated.loc[df_updated.Facility_Level == LABEL_FOR_MERGED_FACILITY_LEVELS_1B_AND_2,
'Total_Mins_Per_Day'].sum(),
df_updated.loc[df_updated.Facility_Level.isin(['1b', '2']), 'Total_Mins_Per_Day'].sum()
)
return df_updated
class FacilityInfo(NamedTuple):
"""Information about a specific health facility."""
id: int
name: str
level: str
region: str
class AppointmentSubunit(NamedTuple):
"""Component of an appointment relating to a specific officer type."""
officer_type: str
time_taken: float
class HSIEventDetails(NamedTuple):
"""Non-target specific details of a health system interaction event."""
event_name: str
module_name: str
treatment_id: str
facility_level: Optional[str]
appt_footprint: Tuple[Tuple[str, int]]
beddays_footprint: Tuple[Tuple[str, int]]
class HSIEventQueueItem(NamedTuple):
"""Properties of event added to health system queue.
The order of the attributes in the tuple is important as the queue sorting is done
by the order of the items in the tuple, i.e. first by `priority`, then `topen` and
so on.
Ensure priority is above topen in order for held-over events with low priority not
to jump ahead higher priority ones which were opened later.
"""
priority: int
topen: Date
rand_queue_counter: int # Ensure order of events with same topen & priority is not model-dependent
queue_counter: int # Include safety tie-breaker in unlikely event rand_queue_counter is equal
tclose: Date
# Define HSI_Event type as string to avoid NameError exception as HSI_Event defined
# later in module (see https://stackoverflow.com/a/36286947/4798943)
hsi_event: 'HSI_Event'
class HSI_Event:
"""Base HSI event class, from which all others inherit.
Concrete subclasses should also inherit from one of the EventMixin classes
defined below, and implement at least an `apply` and `did_not_run` method.
"""
def __init__(self, module, *args, **kwargs):
"""Create a new event.
Note that just creating an event does not schedule it to happen; that
must be done by calling Simulation.schedule_event.
:param module: the module that created this event.
All subclasses of Event take this as the first argument in their
constructor, but may also take further keyword arguments.
"""
self.module = module
self.sim = module.sim
self.target = None # Overwritten by the mixin
super().__init__(*args, **kwargs) # Call the mixin's constructors
# Defaults for the HSI information:
self.TREATMENT_ID = ''
# self.EXPECTED_APPT_FOOTPRINT = self.make_appt_footprint({}) # HSI needs this property, but it is not defined
# in the Base class to allow overwriting with a
# property function.
self.ACCEPTED_FACILITY_LEVEL = None
self.BEDDAYS_FOOTPRINT = self.make_beddays_footprint({})
# Information received about this HSI:
self._received_info_about_bed_days = None
self.expected_time_requests = {}
self.facility_info = None
@property
def bed_days_allocated_to_this_event(self):
if self._received_info_about_bed_days is None:
# default to the footprint if no information about bed-days is received
return self.BEDDAYS_FOOTPRINT
return self._received_info_about_bed_days
def apply(self, squeeze_factor=0.0, *args, **kwargs):
"""Apply this event to the population.
Must be implemented by subclasses.
"""
raise NotImplementedError
def did_not_run(self, *args, **kwargs):
"""Called when this event is due but it is not run. Return False to prevent the event being rescheduled, or True
to allow the rescheduling. This is called each time that the event is tried to be run but it cannot be.
"""
logger.debug(key="message", data=f"{self.__class__.__name__}: did not run.")
return True
def never_ran(self):
"""Called when this event is was entered to the HSI Event Queue, but was never run.
"""
logger.debug(key="message", data=f"{self.__class__.__name__}: was never run.")
def post_apply_hook(self):
"""Impose the bed-days footprint (if target of the HSI is a person_id)"""
if isinstance(self.target, int):
self.module.sim.modules['HealthSystem'].bed_days.impose_beddays_footprint(
person_id=self.target,
footprint=self.bed_days_allocated_to_this_event
)
def run(self, squeeze_factor):
"""Make the event happen."""
updated_appt_footprint = self.apply(self.target, squeeze_factor)
self.post_apply_hook()
return updated_appt_footprint
def get_consumables(self,
item_codes: Union[None, np.integer, int, list, set, dict] = None,
optional_item_codes: Union[None, np.integer, int, list, set, dict] = None,
to_log: Optional[bool] = True,
return_individual_results: Optional[bool] = False
) -> Union[bool, dict]:
"""Function to allow for getting and checking of entire set of consumables. All requests for consumables should
use this function.
:param item_codes: The item code(s) (and quantities) of the consumables that are requested and which determine
the summary result for availability/non-availability. This can be an `int` (the item_code needed [assume
quantity=1]), a `list` or `set` (the collection of item_codes [for each assuming quantity=1]), or a `dict`
(with key:value pairs `<item_code>:<quantity>`).
:param optional_item_codes: The item code(s) (and quantities) of the consumables that are requested and which do
not determine the summary result for availability/non-availability. (Same format as `item_codes`). This is
useful when a large set of items may be used, but the viability of a subsequent operation depends only on a
subset.
:param return_individual_results: If True returns a `dict` giving the availability of each item_code requested
(otherwise gives a `bool` indicating if all the item_codes requested are available).
:param to_log: If True, logs the request.
:returns A `bool` indicating whether every item is available, or a `dict` indicating the availability of each
item.
Note that disease module can use the `get_item_codes_from_package_name` and `get_item_code_from_item_name`
methods in the `HealthSystem` module to find item_codes.
"""
def _return_item_codes_in_dict(item_codes: Union[None, np.integer, int, list, set, dict]) -> dict:
"""Convert an argument for 'item_codes` (provided as int, list, set or dict) into the format
dict(<item_code>:quantity)."""
if item_codes is None:
return {}
if isinstance(item_codes, (int, np.integer)):
return {int(item_codes): 1}
elif isinstance(item_codes, list):
if not all([isinstance(i, (int, np.integer)) for i in item_codes]):
raise ValueError("item_codes must be integers")
return {int(i): 1 for i in item_codes}
elif isinstance(item_codes, dict):
if not all(
[(isinstance(code, (int, np.integer)) and
isinstance(quantity, (float, np.floating, int, np.integer)))
for code, quantity in item_codes.items()]
):
raise ValueError("item_codes must be integers and quantities must be integers or floats.")
return {int(i): float(q) for i, q in item_codes.items()}
else:
raise ValueError("The item_codes are given in an unrecognised format")
hs_module = self.sim.modules['HealthSystem']
_item_codes = _return_item_codes_in_dict(item_codes)
_optional_item_codes = _return_item_codes_in_dict(optional_item_codes)
# Determine if the request should be logged (over-ride argument provided if HealthSystem is disabled).
_to_log = to_log if not hs_module.disable else False
# Checking the availability and logging:
rtn = hs_module.consumables._request_consumables(item_codes={**_item_codes, **_optional_item_codes},
to_log=_to_log,
facility_info=self.facility_info,
treatment_id=self.TREATMENT_ID)
# Return result in expected format:
if not return_individual_results:
# Determine if all results for all the `item_codes` are True (discarding results from optional_item_codes).
return all(v for k, v in rtn.items() if k in _item_codes)
else:
return rtn
def make_beddays_footprint(self, dict_of_beddays):
"""Helper function to make a correctly-formed 'bed-days footprint'"""
# get blank footprint
footprint = self.sim.modules['HealthSystem'].bed_days.get_blank_beddays_footprint()
# do checks on the dict_of_beddays provided.
assert isinstance(dict_of_beddays, dict)
assert all((k in footprint.keys()) for k in dict_of_beddays.keys())
assert all(isinstance(v, (float, int)) for v in dict_of_beddays.values())
# make footprint (defaulting to zero where a type of bed-days is not specified)
for k, v in dict_of_beddays.items():
footprint[k] = v
return footprint
def is_all_beddays_allocated(self):
"""Check if the entire footprint requested is allocated"""
return all(
self.bed_days_allocated_to_this_event[k] == self.BEDDAYS_FOOTPRINT[k] for k in self.BEDDAYS_FOOTPRINT
)
def make_appt_footprint(self, dict_of_appts):
"""Helper function to make appointment footprint in format expected downstream.
Should be passed a dictionary keyed by appointment type codes with non-negative
values.
"""
health_system = self.sim.modules['HealthSystem']
if health_system.appt_footprint_is_valid(dict_of_appts):
return Counter(dict_of_appts)
raise ValueError(
"Argument to make_appt_footprint should be a dictionary keyed by "
"appointment type code strings in Appt_Types_Table with non-negative "
"values"
)
def initialise(self):
"""Initialise the HSI:
* Set the facility_info
* Compute appt-footprint time requirements
"""
health_system = self.sim.modules['HealthSystem']
# Over-write ACCEPTED_FACILITY_LEVEL to to redirect all '1b' appointments to '2'
self.ACCEPTED_FACILITY_LEVEL = adjust_facility_level_to_merge_1b_and_2(self.ACCEPTED_FACILITY_LEVEL)
if not isinstance(self.target, tlo.population.Population):
self.facility_info = health_system.get_facility_info(self)
# If there are bed-days specified, add (if needed) the in-patient admission and in-patient day Appointment
# Types.
# (HSI that require a bed for one or more days always need such appointments, but this may have been
# missed in the declaration of the `EXPECTED_APPT_FOOTPRINT` in the HSI.)
# NB. The in-patient day Appointment time is automatically applied on subsequent days.
if sum(self.BEDDAYS_FOOTPRINT.values()):
self.EXPECTED_APPT_FOOTPRINT = health_system.bed_days.add_first_day_inpatient_appts_to_footprint(
self.EXPECTED_APPT_FOOTPRINT)
# Write the time requirements for staff of the appointments to the HSI:
self.expected_time_requests = health_system.get_appt_footprint_as_time_request(
facility_info=self.facility_info,
appt_footprint=self.EXPECTED_APPT_FOOTPRINT,
)
# Do checks
_ = self._check_if_appt_footprint_can_run()
def _check_if_appt_footprint_can_run(self):
"""Check that event (if individual level) is able to run with this configuration of officers (i.e. check that
this does not demand officers that are _never_ available), and issue warning if not."""
health_system = self.sim.modules['HealthSystem']
if not isinstance(self.target, tlo.population.Population):
if health_system._officers_with_availability.issuperset(self.expected_time_requests.keys()):
return True
else:
logger.warning(
key="message",
data=(f"The expected footprint of {self.TREATMENT_ID} is not possible with the configuration of "
f"officers.")
)
return False
def as_namedtuple(
self, actual_appt_footprint: Optional[dict] = None
) -> HSIEventDetails:
appt_footprint = (
getattr(self, 'EXPECTED_APPT_FOOTPRINT', {})
if actual_appt_footprint is None else actual_appt_footprint
)
return HSIEventDetails(
event_name=type(self).__name__,
module_name=type(self.module).__name__,
treatment_id=self.TREATMENT_ID,
facility_level=getattr(self, 'ACCEPTED_FACILITY_LEVEL', None),
appt_footprint=tuple(sorted(appt_footprint.items())),
beddays_footprint=tuple(
sorted((k, v) for k, v in self.BEDDAYS_FOOTPRINT.items() if v > 0)
)
)
class HSIEventWrapper(Event):
"""This is wrapper that contains an HSI event.
It is used:
1) When the healthsystem is in mode 'disabled=True' such that HSI events sent to the health system scheduler are
passed to the main simulation scheduler for running on the date of `topen`. (Note, it is run with
squeeze_factor=0.0.)
2) When the healthsytsem is in mode `diable_and_reject_all=True` such that HSI are not run but the `never_ran`
method is run on the date of `tclose`.
3) When an HSI has been submitted to `schedule_hsi_event` but the service is not available.
"""
def __init__(self, hsi_event, run_hsi=True, *args, **kwargs):
super().__init__(hsi_event.module, *args, **kwargs)
self.hsi_event = hsi_event
self.target = hsi_event.target
self.run_hsi = run_hsi # True to call the HSI's `run` method; False to call the HSI's `never_ran` method
def run(self):
"""Do the appropriate action on the HSI event"""
# Check that the person is still alive (this check normally happens in the HealthSystemScheduler and silently
# do not run the HSI event)
if isinstance(self.hsi_event.target, tlo.population.Population) or (
self.hsi_event.module.sim.population.props.at[self.hsi_event.target, 'is_alive']
):
if self.run_hsi:
# Run the event (with 0 squeeze_factor) and ignore the output
_ = self.hsi_event.run(squeeze_factor=0.0)
else:
self.hsi_event.module.sim.modules["HealthSystem"].call_and_record_never_ran_hsi_event(
hsi_event=self.hsi_event,
priority=-1
)
def _accepts_argument(function: callable, argument: str) -> bool:
"""Helper to test if callable object accepts an argument with a given name.
Compared to using `inspect.signature` or `inspect.getfullargspec` the approach here
has significantly less overhead (as a full `Signature` or `FullArgSpec` object
does not need to constructed) but is also less readable hence why it has been
wrapped as a helper function despite being only one-line to make its functionality
more obvious.
:param function: Callable object to check if argument is present in.
:param argument: Name of argument to check.
:returns: ``True`` is ``argument`` is an argument of ``function`` else ``False``.
"""
# co_varnames include both arguments to function and any internally defined variable
# names hence we check only in the first `co_argcount` items which correspond to
# just the arguments
return argument in function.__code__.co_varnames[:function.__code__.co_argcount]
class HealthSystem(Module):
"""
This is the Health System Module.
The execution of all health systems interactions are controlled through this module.
"""
INIT_DEPENDENCIES = {'Demography'}
PARAMETERS = {
# Organization of the HealthSystem
'Master_Facilities_List': Parameter(Types.DATA_FRAME, 'Listing of all health facilities.'),
# Definitions of the officers and appointment types
'Officer_Types_Table': Parameter(Types.DATA_FRAME, 'The names of the types of health workers ("officers")'),
'Appt_Types_Table': Parameter(Types.DATA_FRAME, 'The names of the type of appointments with the health system'),
'Appt_Offered_By_Facility_Level': Parameter(
Types.DATA_FRAME, 'Table indicating whether or not each appointment is offered at each facility level.'),
'Appt_Time_Table': Parameter(Types.DATA_FRAME,
'The time taken for each appointment, according to officer and facility type.'),
# Capabilities of the HealthSystem (under alternative assumptions)
'Daily_Capabilities_actual': Parameter(
Types.DATA_FRAME, 'The capabilities (minutes of time available of each type of officer in each facility) '
'based on the _estimated current_ number and distribution of staff estimated.'),
'Daily_Capabilities_funded': Parameter(
Types.DATA_FRAME, 'The capabilities (minutes of time available of each type of officer in each facility) '
'based on the _potential_ number and distribution of staff estimated (i.e. those '
'positions that can be funded).'),
'Daily_Capabilities_funded_plus': Parameter(
Types.DATA_FRAME, 'The capabilities (minutes of time available of each type of officer in each facility) '
'based on the _potential_ number and distribution of staff estimated, with adjustments '
'to permit each appointment type that should be run at facility level to do so in every '
'district.'),
'use_funded_or_actual_staffing': Parameter(
Types.STRING, "If `actual`, then use the numbers and distribution of staff estimated to be available"
" currently; If `funded`, then use the numbers and distribution of staff that are "
"potentially available. If 'funded_plus`, then use a dataset in which the allocation of "
"staff to facilities is tweaked so as to allow each appointment type to run at each "
"facility_level in each district for which it is defined. N.B. This parameter is "
"over-ridden if an argument is provided to the module initialiser.",
# N.B. This could have been of type `Types.CATEGORICAL` but this made over-writing through `Scenario`
# difficult, due to the requirement that the over-writing value and original value are of the same type
# (enforced at line 376 of scenario.py).
),
# Consumables
'item_and_package_code_lookups': Parameter(
Types.DATA_FRAME, 'Data imported from the OneHealth Tool on consumable items, packages and costs.'),
'availability_estimates': Parameter(
Types.DATA_FRAME, 'Estimated availability of consumables in the LMIS dataset.'),
'cons_availability': Parameter(
Types.STRING,
"Availability of consumables. If 'default' then use the availability specified in the ResourceFile; if "
"'none', then let no consumable be ever be available; if 'all', then all consumables are always available."
" When using 'all' or 'none', requests for consumables are not logged. NB. This parameter is over-ridden"
"if an argument is provided to the module initialiser."),
# Infrastructure and Equipment
'BedCapacity': Parameter(
Types.DATA_FRAME, "Data on the number of beds available of each type by facility_id"),
'beds_availability': Parameter(
Types.STRING,
"Availability of beds. If 'default' then use the availability specified in the ResourceFile; if "
"'none', then let no beds be ever be available; if 'all', then all beds are always available. NB. This "
"parameter is over-ridden if an argument is provided to the module initialiser."),
# Service Availability
'Service_Availability': Parameter(
Types.LIST, 'List of services to be available. NB. This parameter is over-ridden if an argument is provided'
' to the module initialiser.'),
'policy_name': Parameter(
Types.STRING, "Name of priority policy assumed to have been adopted until policy switch"),
'year_mode_switch': Parameter(
Types.INT, "Year in which mode switch in enforced"),
'priority_rank': Parameter(
Types.DICT, "Data on the priority ranking of each of the Treatment_IDs to be adopted by "
" the queueing system under different policies, where the lower the number the higher"
" the priority, and on which categories of individuals classify for fast-tracking "
" for specific treatments"),
'absenteeism_table': Parameter(
Types.DICT, "Factors by which capabilities of medical officer categories at different levels will be"
"reduced to account for issues of absenteeism in the workforce."),
'absenteeism_mode': Parameter(
Types.STRING, "Mode of absenteeism considered. Options are default (capabilities are scaled by a "
"constaint factor of 1), data (factors informed by survey data), and custom (user"
"can freely set these factors as parameters in the analysis)."),
'tclose_overwrite': Parameter(
Types.INT, "Decide whether to overwrite tclose variables assigned by disease modules"),
'tclose_days_offset_overwrite': Parameter(
Types.INT, "Offset in days from topen at which tclose will be set by the healthsystem for all HSIs"
"if tclose_overwrite is set to True."),
# Mode Appt Constraints
'mode_appt_constraints': Parameter(
Types.INT, 'Integer code in `{0, 1, 2}` determining mode of constraints with regards to officer numbers '
'and time - 0: no constraints, all HSI events run with no squeeze factor, 1: elastic constraints'
', all HSI events run with squeeze factor, 2: hard constraints, only HSI events with no squeeze '
'factor run. N.B. This parameter is over-ridden if an argument is provided'
' to the module initialiser.',
),
'mode_appt_constraints_postSwitch': Parameter(
Types.INT, 'Mode considered after a mode switch in year_mode_switch.')
}
PROPERTIES = {
'hs_is_inpatient': Property(
Types.BOOL, 'Whether or not the person is currently an in-patient at any medical facility'
),
}
def __init__(
self,
name: Optional[str] = None,
resourcefilepath: Optional[Path] = None,
service_availability: Optional[List[str]] = None,
mode_appt_constraints: Optional[int] = None,
cons_availability: Optional[str] = None,
beds_availability: Optional[str] = None,
randomise_queue: bool = True,
ignore_priority: bool = False,
policy_name: Optional[str] = None,
capabilities_coefficient: Optional[float] = None,
use_funded_or_actual_staffing: Optional[str] = None,
disable: bool = False,
disable_and_reject_all: bool = False,
compute_squeeze_factor_to_district_level: bool = True,
hsi_event_count_log_period: Optional[str] = "month",
):
"""
:param name: Name to use for module, defaults to module class name if ``None``.
:param resourcefilepath: Path to directory containing resource files.
:param service_availability: A list of treatment IDs to allow.
:param mode_appt_constraints: Integer code in ``{0, 1, 2}`` determining mode of
constraints with regards to officer numbers and time - 0: no constraints,
all HSI events run with no squeeze factor, 1: elastic constraints, all HSI
events run with squeeze factor, 2: hard constraints, only HSI events with
no squeeze factor run.
:param cons_availability: If 'default' then use the availability specified in the ResourceFile; if 'none', then
let no consumable be ever be available; if 'all', then all consumables are always available. When using 'all'
or 'none', requests for consumables are not logged.
:param beds_availability: If 'default' then use the availability specified in the ResourceFile; if 'none', then
let no beds be ever be available; if 'all', then all beds are always available.
:param randomise_queue ensure that the queue is not model-dependent, i.e. properly randomised for equal topen
and priority
:param ignore_priority: If ``True`` do not use the priority information in HSI
event to schedule
: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
population.
:param use_funded_or_actual_staffing: If `actual`, then use the numbers and distribution of staff estimated to
be available currently; If `funded`, then use the numbers and distribution of staff that are potentially
available. If 'funded_plus`, then use a dataset in which the allocation of staff to facilities is tweaked
so as to allow each appointment type to run at each facility_level in each district for which it is defined.
:param disable: If ``True``, disables the health system (no constraints and no
logging) and every HSI event runs.
:param disable_and_reject_all: If ``True``, disable health system and no HSI
events run
:param compute_squeeze_factor_to_district_level: Whether to compute squeeze_factors to the district level, or
the national level (which effectively pools the resources across all districts).
:param hsi_event_count_log_period: Period over which to accumulate counts of HSI
events that have run before logging and reseting counters. Should be on of
strings ``'day'``, ``'month'``, ``'year'``. ``'simulation'`` to log at the
end of each day, end of each calendar month, end of each calendar year or
the end of the simulation respectively, or ``None`` to not track the HSI
event details and frequencies.
"""
super().__init__(name)
self.resourcefilepath = resourcefilepath
assert isinstance(disable, bool)
assert isinstance(disable_and_reject_all, bool)
assert not (disable and disable_and_reject_all), (
'Cannot have both disable and disable_and_reject_all selected'
)
assert not (ignore_priority and policy_name is not None), (
'Cannot adopt a priority policy if the priority will be then ignored'
)
self.disable = disable
self.disable_and_reject_all = disable_and_reject_all
self.mode_appt_constraints = None # Will be the final determination of the `mode_appt_constraints'
if mode_appt_constraints is not None:
assert mode_appt_constraints in {0, 1, 2}
self.arg_mode_appt_constraints = mode_appt_constraints
self.rng_for_hsi_queue = None # Will be a dedicated RNG for the purpose of randomising the queue
self.rng_for_dx = None # Will be a dedicated RNG for the purpose of determining Dx Test results
self.randomise_queue = randomise_queue
self.ignore_priority = ignore_priority
# This default value will be overwritten if assumed policy is not None
self.lowest_priority_considered = 2
# Check that the name of policy being evaluated is included
self.priority_policy = None
if policy_name is not None:
assert policy_name in ['', 'Default', 'Test', 'Test Mode 1', 'Random', 'Naive', 'RMNCH',
'VerticalProgrammes', 'ClinicallyVulnerable', 'EHP_III',
'LCOA_EHP']
self.arg_policy_name = policy_name
self.tclose_overwrite = None
self.tclose_days_offset_overwrite = None
# Store the fast tracking channels that will be relevant for policy given the modules included
self.list_fasttrack = [] # provided so that there is a default even before simulation is run
# Store the argument provided for service_availability
self.arg_service_availability = service_availability
self.service_availability = ['*'] # provided so that there is a default even before simulation is run
# Check that the capabilities coefficient is correct
if capabilities_coefficient is not None:
assert capabilities_coefficient >= 0
assert isinstance(capabilities_coefficient, float)
self.capabilities_coefficient = capabilities_coefficient
# Find which set of assumptions to use - those for the actual staff available or the funded staff available
if use_funded_or_actual_staffing is not None:
assert use_funded_or_actual_staffing in ['actual', 'funded', 'funded_plus']
self.arg_use_funded_or_actual_staffing = use_funded_or_actual_staffing
# Define (empty) list of registered disease modules (filled in at `initialise_simulation`)
self.recognised_modules_names = []
# Define the container for calls for health system interaction events
self.HSI_EVENT_QUEUE = []
self.hsi_event_queue_counter = 0 # Counter to help with the sorting in the heapq
# Store the argument provided for cons_availability
assert cons_availability in (None, 'default', 'all', 'none')
self.arg_cons_availability = cons_availability
assert beds_availability in (None, 'default', 'all', 'none')
self.arg_beds_availability = beds_availability
# `compute_squeeze_factor_to_district_level` is a Boolean indicating whether the computation of squeeze_factors
# should be specific to each district (when `True`), or if the computation of squeeze_factors should be on the
# basis that resources from all districts can be effectively "pooled" (when `False).
assert isinstance(compute_squeeze_factor_to_district_level, bool)
self.compute_squeeze_factor_to_district_level = compute_squeeze_factor_to_district_level
# Create the Diagnostic Test Manager to store and manage all Diagnostic Test
self.dx_manager = DxManager(self)
# Create the pointer that will be to the instance of BedDays used to track in-patient bed days
self.bed_days = None
# Create the pointer that will be to the instance of Consumables used to determine availability of consumables.
self.consumables = None
# Create pointer for the HealthSystemScheduler event
self.healthsystemscheduler = None
# Create pointer to the `HealthSystemSummaryCounter` helper class
self._summary_counter = HealthSystemSummaryCounter()
# Create counter for the running total of footprint of all the HSIs being run today
self.running_total_footprint: Counter = Counter()
self._hsi_event_count_log_period = hsi_event_count_log_period
if hsi_event_count_log_period in {"day", "month", "year", "simulation"}:
# Counters for binning HSI events run (by unique integer keys) over
# simulation period specified by hsi_event_count_log_period and cumulative
# counts over previous log periods
self._hsi_event_counts_log_period = Counter()
self._hsi_event_counts_cumulative = Counter()
# Dictionary mapping from HSI event details to unique integer keys
self._hsi_event_details = dict()
# Counters for binning HSI events that never ran (by unique integer keys) over
# simulation period specified by hsi_event_count_log_period and cumulative
# counts over previous log periods
self._never_ran_hsi_event_counts_log_period = Counter()
self._never_ran_hsi_event_counts_cumulative = Counter()
# Dictionary mapping from HSI event details to unique integer keys
self._never_ran_hsi_event_details = dict()
elif hsi_event_count_log_period is not None:
raise ValueError(
"hsi_event_count_log_period argument should be one of 'day', 'month' "
"'year', 'simulation' or None."
)
def read_parameters(self, data_folder):
path_to_resourcefiles_for_healthsystem = Path(self.resourcefilepath) / 'healthsystem'
# Read parameters for overall performance of the HealthSystem
self.load_parameters_from_dataframe(pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'ResourceFile_HealthSystem_parameters.csv'
))
# Load basic information about the organization of the HealthSystem
self.parameters['Master_Facilities_List'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'organisation' / 'ResourceFile_Master_Facilities_List.csv')
# Load ResourceFiles that define appointment and officer types
self.parameters['Officer_Types_Table'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'human_resources' / 'definitions' /
'ResourceFile_Officer_Types_Table.csv')
self.parameters['Appt_Types_Table'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'human_resources' / 'definitions' /
'ResourceFile_Appt_Types_Table.csv')
self.parameters['Appt_Offered_By_Facility_Level'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'human_resources' / 'definitions' /
'ResourceFile_ApptType_By_FacLevel.csv')
self.parameters['Appt_Time_Table'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'human_resources' / 'definitions' /
'ResourceFile_Appt_Time_Table.csv')
# Load 'Daily_Capabilities' (for both actual and funded)
for _i in ['actual', 'funded', 'funded_plus']:
self.parameters[f'Daily_Capabilities_{_i}'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'human_resources' / f'{_i}' /
'ResourceFile_Daily_Capabilities.csv')
# Read in ResourceFile_Consumables
self.parameters['item_and_package_code_lookups'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'consumables' / 'ResourceFile_Consumables_Items_and_Packages.csv')
self.parameters['availability_estimates'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'consumables' / 'ResourceFile_Consumables_availability_small.csv')
# Data on the number of beds available of each type by facility_id
self.parameters['BedCapacity'] = pd.read_csv(
path_to_resourcefiles_for_healthsystem / 'infrastructure_and_equipment' / 'ResourceFile_Bed_Capacity.csv')
# Data on the priority of each Treatment_ID that should be adopted in the queueing system according to different
# priority policies. Load all policies at this stage, and decide later which one to adopt.
self.parameters['priority_rank'] = pd.read_excel(path_to_resourcefiles_for_healthsystem / 'priority_policies' /
'ResourceFile_PriorityRanking_ALLPOLICIES.xlsx',
sheet_name=None)
self.parameters['absenteeism_table'] = pd.read_excel(path_to_resourcefiles_for_healthsystem / 'absenteeism' /
'ResourceFile_Absenteeism.xlsx',
sheet_name=None)
def pre_initialise_population(self):
"""Generate the accessory classes used by the HealthSystem and pass to them the data that has been read."""
# Create dedicated RNGs for separate functions done by the HealthSystem module
self.rng_for_hsi_queue = np.random.RandomState(self.rng.randint(2 ** 31 - 1))
self.rng_for_dx = np.random.RandomState(self.rng.randint(2 ** 31 - 1))
rng_for_consumables = np.random.RandomState(self.rng.randint(2 ** 31 - 1))
# Determine mode_appt_constraints
self.mode_appt_constraints = self.get_mode_appt_constraints()
# Determine service_availability
self.service_availability = self.get_service_availability()
self.process_human_resources_files(
use_funded_or_actual_staffing=self.get_use_funded_or_actual_staffing()
)
# Initialise the BedDays class
self.bed_days = BedDays(hs_module=self,
availability=self.get_beds_availability())
self.bed_days.pre_initialise_population()
# Initialise the Consumables class
self.consumables = Consumables(
data=self.update_consumables_availability_to_represent_merging_of_levels_1b_and_2(
self.parameters['availability_estimates']),
rng=rng_for_consumables,
availability=self.get_cons_availability()
)
self.tclose_overwrite = self.parameters['tclose_overwrite']
self.tclose_days_offset_overwrite = self.parameters['tclose_days_offset_overwrite']
# Ensure name of policy we want to consider before/after switch is among the policies loaded
# in the self.parameters['priority_rank']
assert self.parameters['policy_name'] in self.parameters['priority_rank']
# Set up framework for considering a priority policy
self.setup_priority_policy()
# Ensure the mode of absenteeism to be considered in included in the tables loaded
assert self.parameters['absenteeism_mode'] in self.parameters['absenteeism_table']
# Scale
def initialise_population(self, population):
self.bed_days.initialise_population(population.props)
def initialise_simulation(self, sim):
# If capabilities coefficient was not explicitly specified, use initial population scaling factor
if self.capabilities_coefficient is None:
self.capabilities_coefficient = self.sim.modules['Demography'].initial_model_to_data_popsize_ratio
# Set the tracker in preparation for the simulation
self.bed_days.initialise_beddays_tracker(
model_to_data_popsize_ratio=self.sim.modules['Demography'].initial_model_to_data_popsize_ratio
)
# Set the consumables modules in preparation for the simulation
self.consumables.on_start_of_day(sim.date)
# Capture list of disease modules:
self.recognised_modules_names = [
m.name for m in self.sim.modules.values() if Metadata.USES_HEALTHSYSTEM in m.METADATA
]
# Check that set of districts of residence in population are subset of districts from
# `self._facilities_for_each_district`, which is derived from self.parameters['Master_Facilities_List']
df = self.sim.population.props
districts_of_residence = set(df.loc[df.is_alive, "district_of_residence"].cat.categories)
assert all(
districts_of_residence.issubset(per_level_facilities.keys())
for per_level_facilities in self._facilities_for_each_district.values()
), (
"At least one district_of_residence value in population not present in "
"self._facilities_for_each_district resource file"
)
# Launch the healthsystem scheduler (a regular event occurring each day) [if not disabled]
if not (self.disable or self.disable_and_reject_all):
self.healthsystemscheduler = HealthSystemScheduler(self)
sim.schedule_event(self.healthsystemscheduler, sim.date)
# Schedule a mode_appt_constraints change
sim.schedule_event(HealthSystemChangeMode(self),
Date(self.parameters["year_mode_switch"], 1, 1))
def on_birth(self, mother_id, child_id):
self.bed_days.on_birth(self.sim.population.props, mother_id, child_id)
def on_simulation_end(self):
"""Put out to the log the information from the tracker of the last day of the simulation"""
self.bed_days.on_simulation_end()
self.consumables.on_simulation_end()
if self._hsi_event_count_log_period == "simulation":
self._write_hsi_event_counts_to_log_and_reset()
self._write_never_ran_hsi_event_counts_to_log_and_reset()
if self._hsi_event_count_log_period is not None:
logger_summary.info(
key="hsi_event_details",
description="Map from integer keys to hsi event detail dictionaries",
data={
"hsi_event_key_to_event_details": {
k: d._asdict() for d, k in self._hsi_event_details.items()
}
}
)
logger_summary.info(
key="never_ran_hsi_event_details",
description="Map from integer keys to never ran hsi event detail dictionaries",
data={
"never_ran_hsi_event_key_to_event_details": {
k: d._asdict() for d, k in self._never_ran_hsi_event_details.items()
}
}
)
def setup_priority_policy(self):
# Determine name of policy to be considered **at the start of the simulation**.
self.priority_policy = self.get_priority_policy_initial()
# If adopting a policy, initialise here all other relevant variables.
# Use of blank instead of None is not ideal, however couldn't seem to recover actual
# None from parameter file.
self.load_priority_policy(self.priority_policy)
# Initialise the fast-tracking routes.
# The attributes that can be looked up to determine whether a person might be eligible
# for fast-tracking, as well as the corresponding fast-tracking channels, depend on the modules
# included in the simulation. Store the attributes&channels pairs allowed given the modules included
# to avoid having to recheck which modules are saved every time an HSI_Event is scheduled.
self.list_fasttrack.append(('age_exact_years', 'FT_if_5orUnder'))
if 'Contraception' in self.sim.modules or 'SimplifiedBirths' in self.sim.modules:
self.list_fasttrack.append(('is_pregnant', 'FT_if_pregnant'))
if 'Hiv' in self.sim.modules:
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 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?
# * Define Appointment Types
self._appointment_types = set(self.parameters['Appt_Types_Table']['Appt_Type_Code'])
# * Define the Officers Needed For Each Appointment
# (Store data as dict of dicts, with outer-dict indexed by string facility level and
# inner-dict indexed by string type code with values corresponding to list of (named)
# tuples of appointment officer type codes and time taken.)
appt_time_data = self.parameters['Appt_Time_Table']
appt_times_per_level_and_type = {_facility_level: defaultdict(list) for _facility_level in
self._facility_levels}
for appt_time_tuple in appt_time_data.itertuples():
appt_times_per_level_and_type[
appt_time_tuple.Facility_Level
][
appt_time_tuple.Appt_Type_Code
].append(
AppointmentSubunit(
officer_type=appt_time_tuple.Officer_Category,
time_taken=appt_time_tuple.Time_Taken_Mins
)
)
assert (
sum(
len(appt_info_list)
for level in self._facility_levels
for appt_info_list in appt_times_per_level_and_type[level].values()
) == 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']
self._appt_type_by_facLevel = {
_facility_level: set(
appt_type_per_level_data['Appt_Type_Code'][
appt_type_per_level_data[f'Facility_Level_{_facility_level}']
]
)
for _facility_level in self._facility_levels
}