26 |
27 |
31 | """,
32 | height=height,
33 | width=width
34 | )
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Sammi Rosser
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/examples/simulation_utility_functions.py:
--------------------------------------------------------------------------------
1 | # Utility functions
2 | import simpy
3 |
4 | TRACE = False
5 |
6 | def trace(msg, show=TRACE):
7 | '''
8 | Utility function for printing a trace as the
9 | simulation model executes.
10 | Set the TRACE constant to False, to turn tracing off.
11 |
12 | Params:
13 | -------
14 | msg: str
15 | string to print to screen.
16 | '''
17 | if show:
18 | print(msg)
19 |
20 | class CustomResource(simpy.Resource):
21 | def __init__(self, env, capacity, id_attribute=None):
22 | super().__init__(env, capacity)
23 | self.id_attribute = id_attribute
24 |
25 | def request(self, *args, **kwargs):
26 | # Add logic to handle the ID attribute when a request is made
27 | # For example, you can assign an ID to the requester
28 | # self.id_attribute = assign_id_logic()
29 | return super().request(*args, **kwargs)
30 |
31 | def release(self, *args, **kwargs):
32 | # Add logic to handle the ID attribute when a release is made
33 | # For example, you can reset the ID attribute
34 | # reset_id_logic(self.id_attribute)
35 | return super().release(*args, **kwargs)
36 |
37 |
--------------------------------------------------------------------------------
/examples/ex_5_community_follow_up/data/partial_pooling.csv:
--------------------------------------------------------------------------------
1 | ,clinic_1,clinic_2,clinic_3,clinic_4,clinic_5,clinic_6,clinic_7,clinic_8,clinic_9,clinic_10,clinic_11,clinic_12,clinic_13,clinic_14,clinic_15,clinic_16,clinic_17,clinic_18,clinic_19,clinic_20
2 | clinic_1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
3 | clinic_2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
4 | clinic_3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
5 | clinic_4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
6 | clinic_5,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
7 | clinic_6,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
8 | clinic_7,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
9 | clinic_8,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
10 | clinic_9,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
11 | clinic_10,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
12 | clinic_11,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
13 | clinic_12,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
14 | clinic_13,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
15 | clinic_14,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
16 | clinic_15,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
17 | clinic_16,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
18 | clinic_17,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
19 | clinic_18,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
20 | clinic_19,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
21 | clinic_20,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
22 |
--------------------------------------------------------------------------------
/examples/ex_5_community_follow_up/simulation_summary_functions.py:
--------------------------------------------------------------------------------
1 | import pandas as pd
2 |
3 | def results_summary(results_all, results_low, results_high):
4 | '''
5 | Present model results as a summary data frame
6 |
7 | Params:
8 | ------
9 | results_all: list
10 | - all patient waiting times unfiltered by prirority
11 |
12 | results_low: list
13 | - low prioirty patient waiting times
14 |
15 | results_high: list
16 | - high priority patient waiting times
17 |
18 | Returns:
19 | -------
20 | pd.DataFrame
21 | '''
22 | dfs = []
23 | column_names = []
24 |
25 | if results_all:
26 | results_all_described = pd.DataFrame(results_all).describe()
27 | dfs.append(results_all_described)
28 | column_names.append("All")
29 | if results_low:
30 | results_low_described = pd.DataFrame(results_low).describe()
31 | dfs.append(results_low_described)
32 | column_names.append("Low Priority")
33 | if results_high:
34 | results_high_described = pd.DataFrame(results_high).describe()
35 | dfs.append(results_high_described)
36 | column_names.append("High Priority")
37 |
38 | summary_frame = pd.concat(dfs,
39 | axis=1)
40 |
41 | summary_frame.columns = column_names
42 |
43 | return summary_frame
44 |
--------------------------------------------------------------------------------
/resources/helper_functions.py:
--------------------------------------------------------------------------------
1 | import urllib.request as request
2 | import streamlit as st
3 | import streamlit.components.v1 as components
4 |
5 | def read_file_contents(file_name):
6 | ''''
7 | Read the contents of a file.
8 |
9 | Params:
10 | ------
11 | file_name: str
12 | Path to file.
13 |
14 | Returns:
15 | -------
16 | str
17 | '''
18 | with open(file_name) as f:
19 | return f.read()
20 |
21 |
22 | def read_file_contents_web(path):
23 | """
24 | Download the content of a file from the GitHub Repo and return as a utf-8 string
25 |
26 | Notes:
27 | -------
28 | adapted from 'https://github.com/streamlit/demo-self-driving'
29 |
30 | Parameters:
31 | ----------
32 | path: str
33 | e.g. file_name.md
34 |
35 | Returns:
36 | --------
37 | utf-8 str
38 |
39 | """
40 | response = request.urlopen(path)
41 | return response.read().decode("utf-8")
42 |
43 | def add_logo():
44 | '''
45 | Add a logo at the top of the page navigation sidebar
46 |
47 | Approach written by blackary on
48 | https://discuss.streamlit.io/t/put-logo-and-title-above-on-top-of-page-navigation-in-sidebar-of-multipage-app/28213/5
49 |
50 | '''
51 | st.markdown(
52 | """
53 |
70 | """,
71 | unsafe_allow_html=True,
72 | )
73 |
74 | # From https://discuss.streamlit.io/t/st-markdown-does-not-render-mermaid-graphs/25576/3
75 | def mermaid(code: str, height=600) -> None:
76 | components.html(
77 | f"""
78 |
79 |
80 |
81 | {code}
82 |
83 |
84 |
88 | """,
89 | height=height
90 | )
91 |
--------------------------------------------------------------------------------
/examples/ex_2_branching_and_optional_paths/simulation_execution_functions.py:
--------------------------------------------------------------------------------
1 | # ## Executing a model
2 | import pandas as pd
3 | import numpy as np
4 | from examples.ex_2_branching_and_optional_paths.simulation_summary_functions import SimulationSummary
5 | from examples.ex_2_branching_and_optional_paths.model_classes import TreatmentCentreModel
6 |
7 |
8 | def single_run(scenario,
9 | rc_period=60*24*10,
10 | random_no_set=1,
11 | return_detailed_logs=False,
12 | ):
13 | '''
14 | Perform a single run of the model and return the results
15 |
16 | Parameters:
17 | -----------
18 |
19 | scenario: Scenario object
20 | The scenario/paramaters to run
21 |
22 | rc_period: int
23 | The length of the simulation run that collects results
24 |
25 | random_no_set: int or None, optional (default=DEFAULT_RNG_SET)
26 | Controls the set of random seeds used by the stochastic parts of the
27 | model. Set to different ints to get different results. Set to None
28 | for a random set of seeds.
29 |
30 | Returns:
31 | --------
32 | pandas.DataFrame:
33 | results from single run.
34 | '''
35 | # set random number set - this controls sampling for the run.
36 | scenario.set_random_no_set(random_no_set)
37 |
38 | # create an instance of the model
39 | model = TreatmentCentreModel(scenario)
40 |
41 | # run the model
42 | model.run(results_collection_period=rc_period)
43 |
44 | # run results
45 | summary = SimulationSummary(model)
46 |
47 | summary_df = summary.summary_frame()
48 |
49 | if return_detailed_logs:
50 | event_log = pd.DataFrame(model.event_log)
51 |
52 | return summary_df, event_log
53 |
54 | return summary_df
55 |
56 | def multiple_replications(scenario,
57 | rc_period=60*24*10,
58 | n_reps=10,
59 | return_detailed_logs=False):
60 | '''
61 | Perform multiple replications of the model.
62 |
63 | Params:
64 | ------
65 | scenario: Scenario
66 | Parameters/arguments to configurethe model
67 |
68 | rc_period: float, optional (default=DEFAULT_RESULTS_COLLECTION_PERIOD)
69 | results collection period.
70 | the number of minutes to run the model to collect results
71 |
72 | n_reps: int, optional (default=DEFAULT_N_REPS)
73 | Number of independent replications to run.
74 |
75 | Returns:
76 | --------
77 | pandas.DataFrame
78 | '''
79 |
80 | # If not returning detailed logs, do some additional steps before returning the summary df
81 | if not return_detailed_logs:
82 | results = [single_run(scenario,
83 | rc_period,
84 | random_no_set=(scenario.random_number_set)+rep)
85 | for rep in range(n_reps)]
86 |
87 | # format and return results in a dataframe
88 | df_results_summary = pd.concat(results)
89 | df_results_summary.index = np.arange(1, len(df_results_summary)+1)
90 | df_results_summary.index.name = 'rep'
91 |
92 | return df_results_summary
93 |
94 | else:
95 | detailed_results = [
96 | {
97 | 'rep': rep+1,
98 | 'results': single_run(scenario,
99 | rc_period,
100 | random_no_set=(scenario.random_number_set)+rep,
101 | return_detailed_logs=True)
102 | }
103 | for rep in range(n_reps)
104 | ]
105 |
106 | # format and return results in a dataframe
107 | df_results_summary = pd.concat([result['results'][0] for result in detailed_results])
108 | df_results_summary.index = np.arange(1, len(df_results_summary)+1)
109 | df_results_summary.index.name = 'rep'
110 |
111 | event_log_df = pd.concat(
112 | [
113 | (result['results'][1]).assign(rep = result['rep'])
114 | for result
115 | in detailed_results
116 | ]
117 | )
118 |
119 | # format and return results in a dataframe
120 |
121 | return df_results_summary, event_log_df
122 |
--------------------------------------------------------------------------------
/examples/ex_1_simplest_case/simulation_execution_functions.py:
--------------------------------------------------------------------------------
1 | # ## Executing a model
2 | import pandas as pd
3 | import numpy as np
4 | from examples.ex_1_simplest_case.simulation_summary_functions import SimulationSummary
5 | from examples.ex_1_simplest_case.model_classes import TreatmentCentreModelSimpleNurseStepOnly
6 |
7 |
8 | def single_run(scenario,
9 | rc_period=60*24*10,
10 | random_no_set=1,
11 | return_detailed_logs=False,
12 | ):
13 | '''
14 | Perform a single run of the model and return the results
15 |
16 | Parameters:
17 | -----------
18 |
19 | scenario: Scenario object
20 | The scenario/paramaters to run
21 |
22 | rc_period: int
23 | The length of the simulation run that collects results
24 |
25 | random_no_set: int or None, optional (default=DEFAULT_RNG_SET)
26 | Controls the set of random seeds used by the stochastic parts of the
27 | model. Set to different ints to get different results. Set to None
28 | for a random set of seeds.
29 |
30 | Returns:
31 | --------
32 | pandas.DataFrame:
33 | results from single run.
34 | '''
35 | # set random number set - this controls sampling for the run.
36 | scenario.set_random_no_set(random_no_set)
37 |
38 | # create an instance of the model
39 | model = TreatmentCentreModelSimpleNurseStepOnly(scenario)
40 |
41 | # run the model
42 | model.run(results_collection_period=rc_period)
43 |
44 | # run results
45 | summary = SimulationSummary(model)
46 |
47 | summary_df = summary.summary_frame()
48 |
49 | if return_detailed_logs:
50 | event_log = pd.DataFrame(model.event_log)
51 |
52 | return summary_df, event_log
53 |
54 | return summary_df
55 |
56 |
57 | def multiple_replications(scenario,
58 | rc_period=60*24*10,
59 | n_reps=10,
60 | return_detailed_logs=False):
61 | '''
62 | Perform multiple replications of the model.
63 |
64 | Params:
65 | ------
66 | scenario: Scenario
67 | Parameters/arguments to configurethe model
68 |
69 | rc_period: float, optional (default=DEFAULT_RESULTS_COLLECTION_PERIOD)
70 | results collection period.
71 | the number of minutes to run the model to collect results
72 |
73 | n_reps: int, optional (default=DEFAULT_N_REPS)
74 | Number of independent replications to run.
75 |
76 | Returns:
77 | --------
78 | pandas.DataFrame
79 | '''
80 |
81 | # If not returning detailed logs, do some additional steps before returning the summary df
82 | if not return_detailed_logs:
83 | results = [single_run(scenario,
84 | rc_period,
85 | random_no_set=(scenario.random_number_set)+rep)
86 | for rep in range(n_reps)]
87 |
88 | # format and return results in a dataframe
89 | df_results_summary = pd.concat(results)
90 | df_results_summary.index = np.arange(1, len(df_results_summary)+1)
91 | df_results_summary.index.name = 'rep'
92 |
93 | return df_results_summary
94 |
95 | else:
96 | detailed_results = [
97 | {
98 | 'rep': rep+1,
99 | 'results': single_run(scenario,
100 | rc_period,
101 | random_no_set=(scenario.random_number_set)+rep,
102 | return_detailed_logs=True)
103 | }
104 | for rep in range(n_reps)
105 | ]
106 |
107 | # format and return results in a dataframe
108 | df_results_summary = pd.concat([result['results'][0] for result in detailed_results])
109 | df_results_summary.index = np.arange(1, len(df_results_summary)+1)
110 | df_results_summary.index.name = 'rep'
111 |
112 | event_log_df = pd.concat(
113 | [
114 | (result['results'][1]).assign(rep = result['rep'])
115 | for result
116 | in detailed_results
117 | ]
118 | )
119 |
120 | # format and return results in a dataframe
121 |
122 | return df_results_summary, event_log_df
123 |
124 |
125 |
--------------------------------------------------------------------------------
/Introduction.py:
--------------------------------------------------------------------------------
1 | import streamlit as st
2 | import pandas as pd
3 | import gc
4 |
5 | st.set_page_config(layout="wide", initial_sidebar_state="expanded", page_title="SimPy Visualisation Library")
6 |
7 | gc.collect()
8 |
9 | st.title("Visual Interactive Simulation (VIS) - Demonstration")
10 |
11 | st.markdown(
12 | """
13 | This streamlit app demonstrates the use of [**vidigi**](https://github.com/Bergam0t/vidigi), a visual interactive simulation (VIS) package for showing the position of queues and resource utilisation in a manner understandable to stakeholders.
14 |
15 | It is also valuable for developers, as the functioning of the simulation can be more easily monitored.
16 |
17 | It is designed for integration with simpy - however, in theory, it could be integrated with different simulation packages in Python or other languages, and you can find an example of its use with the *ciw** simulation package in the *vidigi* documentation.
18 |
19 | Please use the tabs on the left hand side to view different examples of how this package can be used.
20 | """
21 | )
22 |
23 | st.divider()
24 |
25 | st.subheader("Models used as examples")
26 |
27 | st.markdown(
28 | """
29 | The underlying code for the emergency department model:
30 | Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) Open Science for Computer Simulation
31 | https://github.com/TomMonks/treatment-centre-sim
32 | """)
33 |
34 | with st.expander("Licence: Treatment Centre Model"):
35 |
36 | st.markdown(
37 | """
38 | MIT License
39 |
40 | Copyright (c) 2021 Tom Monks
41 |
42 | Permission is hereby granted, free of charge, to any person obtaining a copy
43 | of this software and associated documentation files (the "Software"), to deal
44 | in the Software without restriction, including without limitation the rights
45 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
46 | copies of the Software, and to permit persons to whom the Software is
47 | furnished to do so, subject to the following conditions:
48 |
49 | The above copyright notice and this permission notice shall be included in all
50 | copies or substantial portions of the Software.
51 |
52 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
53 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
54 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
55 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
56 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
57 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
58 | SOFTWARE.
59 | """
60 | )
61 |
62 | st.markdown(
63 | """
64 | The layout code for the emergency department model:
65 |
66 |
67 | The hospital efficiency project model:
68 | Harper, A., & Monks, T. Hospital Efficiency Project Orthopaedic Planning Model Discrete-Event Simulation [Computer software].
69 | https://doi.org/10.5281/zenodo.7951080
70 | https://github.com/AliHarp/HEP/tree/main
71 | """
72 | )
73 |
74 | with st.expander("Licence: HEP"):
75 | st.markdown(
76 | """
77 | MIT License
78 |
79 | Copyright (c) 2022 AliHarp
80 |
81 | Permission is hereby granted, free of charge, to any person obtaining a copy
82 | of this software and associated documentation files (the "Software"), to deal
83 | in the Software without restriction, including without limitation the rights
84 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
85 | copies of the Software, and to permit persons to whom the Software is
86 | furnished to do so, subject to the following conditions:
87 |
88 | The above copyright notice and this permission notice shall be included in all
89 | copies or substantial portions of the Software.
90 |
91 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
92 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
93 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
94 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
95 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
96 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
97 | SOFTWARE.
98 | """
99 | )
100 |
--------------------------------------------------------------------------------
/pages/2_Simple_ED_Forced_Overcrowding.py:
--------------------------------------------------------------------------------
1 | import streamlit as st
2 | import pandas as pd
3 | from examples.ex_1_simplest_case.simulation_execution_functions import single_run, multiple_replications
4 | from examples.ex_1_simplest_case.model_classes import Scenario, TreatmentCentreModelSimpleNurseStepOnly
5 | from vidigi.animation import animate_activity_log
6 | import gc
7 |
8 | st.set_page_config(layout="wide",
9 | initial_sidebar_state="expanded",
10 | page_title="Forced Overcrowding - Simple ED")
11 |
12 | gc.collect()
13 |
14 | st.title("Forced Overcrowding Scenario")
15 |
16 | st.markdown(
17 | """
18 | This shows the functionality of changing the maximum number of patients who will be displayed at any step.
19 | This is important for preventing the animation from becoming too large and unwieldy in situations with large bottlenecks,
20 | where the position of very large numbers of patients will end up being tracked at every point.
21 |
22 | In this example, we end up with a queue of over 2000 entities being managed by vidigi.
23 | """
24 | )
25 |
26 | st.warning(
27 | """
28 | Despite these adjustments, this simulation takes quite a long time to run - it may take 1 to 1.5 minutes to load after clicking the button.
29 | """
30 | )
31 |
32 | button_run_pressed = st.button("Run simulation")
33 |
34 | if button_run_pressed:
35 |
36 | # add a spinner and then display success box
37 | with st.spinner('Simulating the minor injuries unit...'):
38 |
39 | args = Scenario(manual_arrival_rate=3,
40 | n_cubicles_1=5)
41 |
42 | model = TreatmentCentreModelSimpleNurseStepOnly(args)
43 |
44 | st.subheader("Single Run")
45 |
46 | results_df = single_run(args)
47 |
48 | st.dataframe(results_df)
49 |
50 | st.subheader("Multiple Runs")
51 |
52 | df_results_summary, detailed_results = multiple_replications(
53 | args,
54 | n_reps=5,
55 | rc_period=10*60*24,
56 | return_detailed_logs=True
57 | )
58 |
59 | st.dataframe(df_results_summary)
60 | # st.dataframe(detailed_results)
61 |
62 | # animation_df = reshape_for_animations(
63 | # event_log=detailed_results[detailed_results['rep']==1],
64 | # every_x_time_units=10,
65 | # limit_duration=10*60*24,
66 | # step_snapshot_max=50
67 | # )
68 |
69 | # st.dataframe(
70 | # animation_df
71 | # )
72 |
73 |
74 | event_position_df = pd.DataFrame([
75 | {'event': 'arrival', 'x': 50, 'y': 300, 'label': "Arrival" },
76 |
77 | # Triage - minor and trauma
78 | {'event': 'treatment_wait_begins', 'x': 205, 'y': 270, 'label': "Waiting for Treatment" },
79 | {'event': 'treatment_begins', 'x': 205, 'y': 170, 'resource':'n_cubicles_1', 'label': "Being Treated" },
80 |
81 | {'event': 'exit', 'x': 270, 'y': 70, 'label': "Exit"}
82 |
83 | ])
84 |
85 |
86 | st.plotly_chart(
87 | animate_activity_log(
88 | event_log=detailed_results[detailed_results['rep']==1],
89 | event_position_df= event_position_df,
90 | scenario=args,
91 | entity_col_name="patient",
92 | debug_mode=True,
93 | every_x_time_units=5,
94 | include_play_button=True,
95 | text_size=20,
96 | entity_icon_size=20,
97 | gap_between_entities=6,
98 | gap_between_queue_rows=25,
99 | plotly_height=700,
100 | plotly_width=1200,
101 | override_x_max=300,
102 | override_y_max=500,
103 | wrap_queues_at=25,
104 | step_snapshot_max=125,
105 | time_display_units="dhm",
106 | display_stage_labels=False,
107 | add_background_image="https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/resources/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
108 | ), use_container_width=False,
109 | config = {'displayModeBar': False}
110 | )
111 |
--------------------------------------------------------------------------------
/examples/ex_3_theatres_beds/simulation_summary_functions.py:
--------------------------------------------------------------------------------
1 | # Summary results across days and runs
2 |
3 | # Overall summary of results across all runs, and across model run time.
4 |
5 | # Used for validation
6 |
7 | import numpy as np
8 | import pandas as pd
9 |
10 | DEFAULT_WARM_UP_PERIOD = 7
11 | DEFAULT_RESULTS_COLLECTION_PERIOD = 42
12 |
13 | class Summary:
14 | """
15 | summary results across run
16 | """
17 | def __init__(self, model):
18 | """ model: Hospital """
19 |
20 | self.model = model
21 | self.args = model.args
22 | self.summary_results = None
23 |
24 | def process_run_results(self):
25 | self.summary_results = {}
26 |
27 | #all patients arrived during results collection period
28 | patients = len([p for p in self.model.cum_primary_patients if p.day > DEFAULT_WARM_UP_PERIOD])+\
29 | len([p for p in self.model.cum_revision_patients if p.day > DEFAULT_WARM_UP_PERIOD])
30 |
31 | primary_arrivals = len([p for p in self.model.cum_primary_patients if p.day > DEFAULT_WARM_UP_PERIOD])
32 | revision_arrivals = len([p for p in self.model.cum_revision_patients if p.day > DEFAULT_WARM_UP_PERIOD])
33 |
34 | #throughput during results collection period
35 | primary_throughput = len([p for p in self.model.cum_primary_patients if (p.total_time > -np.inf)
36 | & (p.day > DEFAULT_WARM_UP_PERIOD)])
37 | revision_throughput = len([p for p in self.model.cum_revision_patients if (p.total_time > -np.inf)
38 | & (p.day > DEFAULT_WARM_UP_PERIOD)])
39 |
40 | #mean queues - this also includes patients who renege and therefore have 0 queue
41 | mean_primary_queue_beds = np.array([getattr(p, 'queue_beds') for p in self.model.cum_primary_patients
42 | if getattr(p, 'queue_beds') > -np.inf]).mean()
43 | mean_revision_queue_beds = np.array([getattr(p, 'queue_beds') for p in self.model.cum_revision_patients
44 | if getattr(p, 'queue_beds') > -np.inf]).mean()
45 |
46 | #check mean los
47 | mean_primary_los = np.array([getattr(p, 'primary_los') for p in self.model.cum_primary_patients
48 | if getattr(p, 'primary_los') > 0]).mean()
49 | mean_revision_los = np.array([getattr(p, 'revision_los') for p in self.model.cum_revision_patients
50 | if getattr(p, 'revision_los') > 0]).mean()
51 |
52 | #bed utilisation primary and revision patients during results collection period
53 | los_primary = np.array([getattr(p,'primary_los') for p in self.model.cum_primary_patients
54 | if (getattr(p, 'primary_los') > -np.inf) & (getattr(p, 'day') > DEFAULT_WARM_UP_PERIOD)]).sum()
55 | mean_primary_bed_utilisation = los_primary / (DEFAULT_RESULTS_COLLECTION_PERIOD * self.args.n_beds)
56 | los_revision = np.array([getattr(p,'revision_los') for p in self.model.cum_revision_patients
57 | if (getattr(p, 'revision_los') > -np.inf) & (getattr(p, 'day') > DEFAULT_WARM_UP_PERIOD)]).sum()
58 | mean_revision_bed_utilisation = los_revision / (DEFAULT_RESULTS_COLLECTION_PERIOD * self.args.n_beds)
59 |
60 | self.summary_results = {'arrivals':patients,
61 | 'primary_arrivals':primary_arrivals,
62 | 'revision_arrivals':revision_arrivals,
63 | 'primary_throughput':primary_throughput,
64 | 'revision_throughput':revision_throughput,
65 | 'primary_queue':mean_primary_queue_beds,
66 | 'revision_queue':mean_revision_queue_beds,
67 | 'mean_primary_los':mean_primary_los,
68 | 'mean_revision_los':mean_revision_los,
69 | 'primary_bed_utilisation':mean_primary_bed_utilisation,
70 | 'revision_bed_utilisation':mean_revision_bed_utilisation}
71 |
72 | def summary_frame(self):
73 | if self.summary_results is None:
74 | self.process_run_results()
75 | df = pd.DataFrame({'1':self.summary_results})
76 | df = df.T
77 | df.index.name = 'rep'
78 | return df
79 |
--------------------------------------------------------------------------------
/examples/ex_3_theatres_beds/simulation_execution_functions.py:
--------------------------------------------------------------------------------
1 | import pandas as pd
2 | import numpy as np
3 | from examples.ex_3_theatres_beds.model_classes import Schedule, Hospital
4 | from examples.ex_3_theatres_beds.simulation_summary_functions import Summary
5 |
6 | # Model execution
7 |
8 | # A single_run returns three data sets: summary, daily, patient-level
9 |
10 | # The function multiple_reps calls single_run for the number of replications.
11 |
12 | def single_run(scenario,
13 | results_collection=42+7,
14 | random_no_set=None,
15 | return_detailed_logs=False):
16 | """
17 | summary results for a single run which can be called for multiple runs
18 | 1. summary of single run
19 | 2. daily audit of mean results per day
20 | 3a. primary patient results for one run and all days
21 | 3b. revision patient results for one run and all days
22 | """
23 | scenario.set_random_no_set(random_no_set)
24 | schedule = Schedule()
25 | model = Hospital(scenario)
26 | model.run(results_collection = results_collection)
27 | summary = Summary(model)
28 |
29 | #summary results for a single run
30 | #(warmup excluded apart from bed utilisation AND throughput)
31 | summary_df = summary.summary_frame()
32 |
33 | #summary per day results for a single run (warmup excluded)
34 | results_per_day = model.results
35 |
36 | #patient-level results (includes warmup results)
37 | patient_results = model.patient_results()
38 |
39 | if return_detailed_logs:
40 | event_log = pd.DataFrame(model.event_log)
41 |
42 | return (summary_df, results_per_day, patient_results, event_log)
43 |
44 | return(summary_df, results_per_day, patient_results)
45 |
46 | def multiple_replications(scenario,
47 | results_collection=42,
48 | warmup=7,
49 | n_reps=30,
50 | return_detailed_logs=False):
51 | """
52 | create dataframes of summary results across multiple runs:
53 | 1. summary table per run
54 | 2. summary table per run and per day
55 | 3a. primary patient results for all days and all runs
56 | 3b. revision patient results for all days and all runs
57 | """
58 |
59 | results_collection_plus_warmup = results_collection + warmup
60 | #summary per run for multiple reps
61 | #(warm-up excluded apart from bed utilisation AND throughput)
62 |
63 | # all_results =
64 |
65 | results = [single_run(scenario, results_collection_plus_warmup, random_no_set=rep)[0]
66 | for rep in range(n_reps)]
67 | df_results = pd.concat(results)
68 | df_results.index = np.arange(1, len(df_results)+1)
69 | df_results.index.name = 'rep'
70 |
71 | #summary per day per run for multiple reps (warmup excluded)
72 | day_results = [single_run(scenario, results_collection_plus_warmup, random_no_set=rep)[1]
73 | for rep in range(n_reps)]
74 |
75 | length_run = [*range(1, results_collection_plus_warmup-warmup+1)]
76 | length_reps = [*range(1, n_reps+1)]
77 | run = [rep for rep in length_reps for i in length_run]
78 |
79 | df_day_results = pd.concat(day_results)
80 | df_day_results['run'] = run
81 |
82 | #patient results for all days and all runs (warmup included)
83 | primary_pt_results = [single_run(scenario, results_collection_plus_warmup, random_no_set=rep)[2][0].assign(rep = rep+1)
84 | for rep in range(n_reps)]
85 | primary_pt_results = pd.concat(primary_pt_results)
86 |
87 | revision_pt_results = [single_run(scenario, results_collection_plus_warmup, random_no_set=rep)[2][1].assign(rep = rep+1)
88 | for rep in range(n_reps)]
89 | revision_pt_results = pd.concat(revision_pt_results)
90 |
91 | if return_detailed_logs:
92 | event_log = [single_run(scenario,
93 | results_collection_plus_warmup,
94 | random_no_set=rep,
95 | return_detailed_logs=True)[3].assign(rep = rep+1)
96 | for rep in range(n_reps)]
97 | event_log = pd.concat(event_log)
98 |
99 |
100 | return (df_results, df_day_results, primary_pt_results, revision_pt_results, event_log)
101 |
102 | return (df_results, df_day_results, primary_pt_results, revision_pt_results)
--------------------------------------------------------------------------------
/examples/ex_1_simplest_case/simulation_summary_functions.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 |
4 | class SimulationSummary:
5 | '''
6 | End of run result processing logic of the simulation model
7 | '''
8 |
9 | def __init__(self, model):
10 | '''
11 | Constructor
12 |
13 | Params:
14 | ------
15 | model: TraumaCentreModel
16 | The model.
17 | '''
18 | self.model = model
19 | self.args = model.args
20 | self.results = None
21 | self.patient_log = None
22 | self.event_log = model.event_log
23 | self.utilisation_audit = model.utilisation_audit
24 |
25 | def get_mean_metric(self, metric, patients):
26 | '''
27 | Calculate mean of the performance measure for the
28 | select cohort of patients,
29 |
30 | Only calculates metrics for patients where it has been
31 | measured.
32 |
33 | Params:
34 | -------
35 | metric: str
36 | The name of the metric e.g. 'wait_treat'
37 |
38 | patients: list
39 | A list of patients
40 | '''
41 | mean = np.array([getattr(p, metric) for p in patients
42 | if getattr(p, metric) > -np.inf]).mean()
43 | return mean
44 |
45 | def get_perc_wait_target_met(self, metric, patients, target):
46 | '''
47 | Calculate the percentage of patients where a target was met for
48 | the select cohort of patients,
49 |
50 | Only calculates metrics for patients where it has been
51 | measured.
52 |
53 | Params:
54 | -------
55 | metric: str
56 | The name of the metric e.g. 'wait_treat'
57 |
58 | patients: list
59 | A list of patients
60 | '''
61 | met = len(np.array([getattr(p, metric) for p in patients
62 | if getattr(p, metric) < target]))
63 | total = len(np.array([getattr(p, metric) for p in patients
64 | if getattr(p, metric) > -np.inf]))
65 |
66 | return met/total
67 |
68 | def get_resource_util(self, metric, n_resources, patients):
69 | '''
70 | Calculate proportion of the results collection period
71 | where a resource was in use.
72 |
73 | Done by tracking the duration by patient.
74 |
75 | Only calculates metrics for patients where it has been
76 | measured.
77 |
78 | Params:
79 | -------
80 | metric: str
81 | The name of the metric e.g. 'treatment_duration'
82 |
83 | patients: list
84 | A list of patients
85 | '''
86 | total = np.array([getattr(p, metric) for p in patients
87 | if getattr(p, metric) > -np.inf]).sum()
88 |
89 | return total / (self.model.rc_period * n_resources)
90 |
91 | def get_throughput(self, patients):
92 | '''
93 | Returns the total number of patients that have successfully
94 | been processed and discharged in the treatment centre
95 | (they have a total time record)
96 |
97 | Params:
98 | -------
99 | patients: list
100 | list of all patient objects simulated.
101 |
102 | Returns:
103 | ------
104 | float
105 | '''
106 | return len([p for p in patients if p.total_time > -np.inf])
107 |
108 |
109 | def process_run_results(self,
110 | wait_target_per_step=120):
111 | '''
112 | Calculates statistics at end of run.
113 | '''
114 | self.results = {}
115 |
116 | self.patient_log = self.model.patients
117 |
118 | self.results = {'00_arrivals': len(self.model.patients),
119 | '01a_treatment_wait': self.get_mean_metric('wait_treat', self.model.patients),
120 | '01b_treatment_util': self.get_resource_util('treat_duration', self.args.n_cubicles_1,self.model.patients),
121 | '01c_treatment_wait_target_met': self.get_perc_wait_target_met('wait_treat', self.model.patients,target=wait_target_per_step),
122 | '02_total_time': self.get_mean_metric('total_time', self.model.patients),
123 | '03_throughput': self.get_throughput(self.model.patients)
124 | }
125 |
126 |
127 |
128 | def summary_frame(self):
129 | '''
130 | Returns run results as a pandas.DataFrame
131 |
132 | Returns:
133 | -------
134 | pd.DataFrame
135 | '''
136 | # append to results df
137 | if self.results is None:
138 | self.process_run_results()
139 |
140 | df = pd.DataFrame({'1': self.results})
141 | df = df.T
142 | df.index.name = 'rep'
143 | return df
144 |
145 | def detailed_logs(self):
146 | '''
147 | Returns run results as a pandas.DataFrame
148 |
149 | Returns:
150 | -------
151 | pd.DataFrame
152 | '''
153 | # append to results df
154 | if self.event_log is None:
155 | self.process_run_results()
156 |
157 | return {
158 | 'patient': self.patient_log,
159 | 'event_log': self.event_log,
160 | 'results_summary': self.results
161 | }
162 |
--------------------------------------------------------------------------------
/pages/1_Simple_ED_Interactive.py:
--------------------------------------------------------------------------------
1 | import streamlit as st
2 | import pandas as pd
3 | import plotly.express as px
4 | from examples.ex_1_simplest_case.simulation_execution_functions import single_run, multiple_replications
5 | from examples.ex_1_simplest_case.model_classes import Scenario, TreatmentCentreModelSimpleNurseStepOnly
6 | from examples.distribution_classes import Normal
7 | from vidigi.animation import animate_activity_log
8 | import gc
9 |
10 | st.set_page_config(layout="wide",
11 | initial_sidebar_state="expanded",
12 | page_title="Forced Overcrowding - Simple ED")
13 |
14 | st.title("Simple Interactive Treatment Step")
15 |
16 | st.markdown(
17 | """
18 | This interactive simulation shows the simplest use of the animated event log.
19 |
20 | On changing the values of the sliders, you can click 'run simulation' again to generate an updated animation.
21 | """
22 | )
23 |
24 | gc.collect()
25 |
26 | col1, col2 = st.columns(2)
27 |
28 | with col1:
29 |
30 | nurses = st.slider("👨⚕️👩⚕️ How Many Rooms/Nurses Are Available?", 1, 15, step=1, value=4)
31 |
32 | seed = st.slider("🎲 Set a random number for the computer to start from",
33 | 1, 1000,
34 | step=1, value=42)
35 |
36 | with st.expander("Previous Parameters"):
37 |
38 | st.markdown("If you like, you can edit these parameters too!")
39 |
40 | n_reps = st.slider("🔁 How many times should the simulation run?",
41 | 1, 30,
42 | step=1, value=6)
43 |
44 | run_time_days = st.slider("🗓️ How many days should we run the simulation for each time?",
45 | 1, 40,
46 | step=1, value=10)
47 |
48 |
49 | mean_arrivals_per_day = st.slider("🧍 How many patients should arrive per day on average?",
50 | 10, 300,
51 | step=5, value=120)
52 |
53 | with col2:
54 |
55 | consult_time = st.slider("⏱️ How long (in minutes) does a consultation take on average?",
56 | 5, 150, step=5, value=50)
57 |
58 | consult_time_sd = st.slider("🕔 🕣 How much (in minutes) does the time for a consultation usually vary by?",
59 | 5, 30, step=5, value=10)
60 |
61 | norm_dist = Normal(consult_time, consult_time_sd, random_seed=seed)
62 | norm_fig = px.histogram(norm_dist.sample(size=2500), height=150)
63 |
64 | norm_fig.update_layout(yaxis_title="", xaxis_title="Consultation Time (Minutes)")
65 |
66 | norm_fig.update_xaxes(tick0=0, dtick=10, range=[0,
67 | # max(norm_dist.sample(size=2500))
68 | 240
69 | ])
70 |
71 |
72 |
73 | norm_fig.layout.update(showlegend=False,
74 | margin=dict(l=0, r=0, t=0, b=0))
75 |
76 | st.markdown("#### Consultation Time Distribution")
77 | st.plotly_chart(norm_fig,
78 | use_container_width=True,
79 | config = {'displayModeBar': False})
80 |
81 |
82 |
83 | # A user must press a streamlit button to run the model
84 | button_run_pressed = st.button("Run simulation")
85 |
86 |
87 | if button_run_pressed:
88 |
89 | # add a spinner and then display success box
90 | with st.spinner('Simulating the minor injuries unit...'):
91 |
92 | args = Scenario(manual_arrival_rate=60/(mean_arrivals_per_day/24),
93 | n_cubicles_1=nurses,
94 | random_number_set=seed,
95 | trauma_treat_mean=consult_time,
96 | trauma_treat_var=consult_time_sd)
97 |
98 | model = TreatmentCentreModelSimpleNurseStepOnly(args)
99 |
100 | st.subheader("Single Run")
101 |
102 | results_df = single_run(args)
103 |
104 | st.dataframe(results_df)
105 |
106 | st.subheader("Multiple Runs")
107 |
108 | df_results_summary, detailed_results = multiple_replications(
109 | args,
110 | n_reps=n_reps,
111 | rc_period=run_time_days*24*60,
112 | return_detailed_logs=True
113 | )
114 |
115 | st.dataframe(df_results_summary)
116 | # st.dataframe(detailed_results)
117 |
118 | # animation_df = reshape_for_animations(
119 | # event_log=detailed_results[detailed_results['rep']==1],
120 | # every_x_time_units=10,
121 | # limit_duration=10*60*24,
122 | # step_snapshot_max=50
123 | # )
124 |
125 | # st.dataframe(
126 | # animation_df
127 | # )
128 |
129 |
130 | event_position_df = pd.DataFrame([
131 | {'event': 'arrival', 'x': 50, 'y': 300, 'label': "Arrival" },
132 |
133 | # Triage - minor and trauma
134 | {'event': 'treatment_wait_begins', 'x': 205, 'y': 270, 'label': "Waiting for Treatment" },
135 | {'event': 'treatment_begins', 'x': 205, 'y': 170, 'resource':'n_cubicles_1', 'label': "Being Treated" },
136 |
137 | {'event': 'exit', 'x': 270, 'y': 70, 'label': "Exit"}
138 |
139 | ])
140 |
141 |
142 | st.plotly_chart(
143 | animate_activity_log(
144 | event_log=detailed_results[detailed_results['rep']==1],
145 | event_position_df= event_position_df,
146 | scenario=args,
147 | entity_col_name="patient",
148 | debug_mode=True,
149 | every_x_time_units=5,
150 | include_play_button=True,
151 | entity_icon_size=20,
152 | text_size=20,
153 | gap_between_entities=6,
154 | gap_between_queue_rows=25,
155 | plotly_height=700,
156 | plotly_width=1200,
157 | override_x_max=300,
158 | override_y_max=500,
159 | wrap_queues_at=25,
160 | step_snapshot_max=125,
161 | time_display_units="dhm",
162 | display_stage_labels=False,
163 | add_background_image="https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/resources/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
164 | ), use_container_width=False,
165 | config = {'displayModeBar': False}
166 | )
167 |
--------------------------------------------------------------------------------
/pages/3_Complex_ED_Interactive.py:
--------------------------------------------------------------------------------
1 | import streamlit as st
2 | import pandas as pd
3 | import plotly.express as px
4 | from examples.ex_2_branching_and_optional_paths.simulation_execution_functions import (
5 | single_run,
6 | multiple_replications,
7 | )
8 | from examples.ex_2_branching_and_optional_paths.model_classes import (
9 | Scenario,
10 | TreatmentCentreModel,
11 | )
12 | from vidigi.animation import animate_activity_log
13 | import gc
14 |
15 | st.set_page_config(
16 | layout="wide", initial_sidebar_state="expanded", page_title="Complex ED"
17 | )
18 |
19 | st.title("Simple Interactive Treatment Step")
20 |
21 | st.markdown(
22 | """
23 | This interactive simulation builds on the previous examples to demonstrate a multi-step, branching pathway with some optional steps.
24 | """
25 | )
26 |
27 | gc.collect()
28 |
29 | col1, col2, col3, col4 = st.columns(4)
30 |
31 | with col1:
32 | st.subheader("Triage")
33 | n_triage = st.slider("👨⚕️👩⚕️ Number of Triage Cubicles", 1, 10, step=1, value=4)
34 | prob_trauma = st.slider(
35 | "🚑 Probability that a new arrival is a trauma patient",
36 | 0.0,
37 | 1.0,
38 | step=0.01,
39 | value=0.3,
40 | help="0 = No arrivals are trauma patients, 1 = All arrivals are trauma patients",
41 | )
42 |
43 | with col2:
44 | st.subheader("Trauma Pathway")
45 | n_trauma = st.slider(
46 | "👨⚕️👩⚕️ Number of Trauma Bays for Stabilisation", 1, 10, step=1, value=6
47 | )
48 | n_cubicles_2 = st.slider(
49 | "👨⚕️👩⚕️ Number of Treatment Cubicles for Trauma", 1, 10, step=1, value=6
50 | )
51 |
52 | with col3:
53 | st.subheader("Non-Trauma Pathway")
54 | n_reg = st.slider("👨⚕️👩⚕️ Number of Registration Cubicles", 1, 10, step=1, value=3)
55 | n_exam = st.slider(
56 | "👨⚕️👩⚕️ Number of Examination Rooms for non-trauma patients",
57 | 1,
58 | 10,
59 | step=1,
60 | value=3,
61 | )
62 |
63 | with col4:
64 | st.subheader("Non-Trauma Treatment")
65 | n_cubicles_1 = st.slider(
66 | "👨⚕️👩⚕️ Number of Treatment Cubicles for Non-Trauma", 1, 10, step=1, value=2
67 | )
68 | non_trauma_treat_p = st.slider(
69 | "🤕 Probability that a non-trauma patient will need treatment",
70 | 0.0,
71 | 1.0,
72 | step=0.01,
73 | value=0.7,
74 | help="0 = No non-trauma patients need treatment, 1 = All non-trauma patients need treatment",
75 | )
76 |
77 |
78 | col5, col6 = st.columns(2)
79 | with col5:
80 | st.write(
81 | "Total rooms in use is {}".format(
82 | n_cubicles_1 + n_cubicles_2 + n_exam + n_trauma + n_triage + n_reg
83 | )
84 | )
85 | with col6:
86 | with st.expander("Advanced Parameters"):
87 | seed = st.slider(
88 | "🎲 Set a random number for the computer to start from",
89 | 1,
90 | 1000,
91 | step=1,
92 | value=42,
93 | )
94 |
95 | n_reps = st.slider(
96 | "🔁 How many times should the simulation run? WARNING: Fast/modern computer required to take this above 5 replications.",
97 | 1,
98 | 10,
99 | step=1,
100 | value=3,
101 | )
102 |
103 | run_time_days = st.slider(
104 | "🗓️ How many days should we run the simulation for each time?",
105 | 1,
106 | 60,
107 | step=1,
108 | value=5,
109 | )
110 |
111 |
112 | # A user must press a streamlit button to run the model
113 | button_run_pressed = st.button("Run simulation")
114 |
115 | if button_run_pressed:
116 | # add a spinner and then display success box
117 | with st.spinner("Simulating the department..."):
118 | args = Scenario(
119 | random_number_set=seed,
120 | n_triage=n_triage,
121 | n_reg=n_reg,
122 | n_exam=n_exam,
123 | n_trauma=n_trauma,
124 | n_cubicles_1=n_cubicles_1,
125 | n_cubicles_2=n_cubicles_2,
126 | non_trauma_treat_p=non_trauma_treat_p,
127 | prob_trauma=prob_trauma,
128 | )
129 |
130 | model = TreatmentCentreModel(args)
131 |
132 | st.subheader("Single Run")
133 |
134 | results_df = single_run(args)
135 |
136 | st.dataframe(results_df)
137 |
138 | st.subheader("Multiple Runs")
139 |
140 | df_results_summary, detailed_results = multiple_replications(
141 | args,
142 | n_reps=n_reps,
143 | rc_period=run_time_days * 24 * 60,
144 | return_detailed_logs=True,
145 | )
146 |
147 | st.dataframe(df_results_summary)
148 |
149 | event_position_df = pd.DataFrame(
150 | [
151 | # {'event': 'arrival', 'x': 10, 'y': 250, 'label': "Arrival" },
152 | # Triage - minor and trauma
153 | {
154 | "event": "triage_wait_begins",
155 | "x": 155,
156 | "y": 400,
157 | "label": "Waiting for Triage",
158 | },
159 | {
160 | "event": "triage_begins",
161 | "x": 155,
162 | "y": 315,
163 | "resource": "n_triage",
164 | "label": "Being Triaged",
165 | },
166 | # Minors (non-trauma) pathway
167 | {
168 | "event": "MINORS_registration_wait_begins",
169 | "x": 295,
170 | "y": 145,
171 | "label": "Waiting for Registration",
172 | },
173 | {
174 | "event": "MINORS_registration_begins",
175 | "x": 295,
176 | "y": 85,
177 | "resource": "n_reg",
178 | "label": "Being Registered",
179 | },
180 | {
181 | "event": "MINORS_examination_wait_begins",
182 | "x": 460,
183 | "y": 145,
184 | "label": "Waiting for Examination",
185 | },
186 | {
187 | "event": "MINORS_examination_begins",
188 | "x": 460,
189 | "y": 85,
190 | "resource": "n_exam",
191 | "label": "Being Examined",
192 | },
193 | {
194 | "event": "MINORS_treatment_wait_begins",
195 | "x": 625,
196 | "y": 145,
197 | "label": "Waiting for Treatment",
198 | },
199 | {
200 | "event": "MINORS_treatment_begins",
201 | "x": 625,
202 | "y": 85,
203 | "resource": "n_cubicles_1",
204 | "label": "Being Treated",
205 | },
206 | # Trauma pathway
207 | {
208 | "event": "TRAUMA_stabilisation_wait_begins",
209 | "x": 295,
210 | "y": 540,
211 | "label": "Waiting for Stabilisation",
212 | },
213 | {
214 | "event": "TRAUMA_stabilisation_begins",
215 | "x": 295,
216 | "y": 480,
217 | "resource": "n_trauma",
218 | "label": "Being Stabilised",
219 | },
220 | {
221 | "event": "TRAUMA_treatment_wait_begins",
222 | "x": 625,
223 | "y": 540,
224 | "label": "Waiting for Treatment",
225 | },
226 | {
227 | "event": "TRAUMA_treatment_begins",
228 | "x": 625,
229 | "y": 480,
230 | "resource": "n_cubicles_2",
231 | "label": "Being Treated",
232 | },
233 | {"event": "exit", "x": 670, "y": 330, "label": "Exit"},
234 | ]
235 | )
236 |
237 | st.plotly_chart(
238 | animate_activity_log(
239 | event_log=detailed_results[detailed_results["rep"] == 1],
240 | event_position_df=event_position_df,
241 | scenario=args,
242 | entity_col_name="patient",
243 | debug_mode=True,
244 | limit_duration=run_time_days * 24 * 60,
245 | every_x_time_units=5,
246 | include_play_button=True,
247 | gap_between_entities=10,
248 | gap_between_queue_rows=25,
249 | plotly_height=700,
250 | plotly_width=1200,
251 | override_x_max=700,
252 | override_y_max=675,
253 | text_size=22,
254 | entity_icon_size=18,
255 | wrap_queues_at=10,
256 | step_snapshot_max=30,
257 | time_display_units="dhm_ampm",
258 | display_stage_labels=False,
259 | add_background_image="https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/resources/Full%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
260 | ),
261 | use_container_width=False,
262 | config={"displayModeBar": False},
263 | )
264 |
--------------------------------------------------------------------------------
/examples/distribution_classes.py:
--------------------------------------------------------------------------------
1 |
2 | '''
3 | Distribution classes
4 |
5 | To help with controlling sampling `numpy` distributions are packaged up into
6 | classes that allow easy control of random numbers.
7 |
8 | **Distributions included:**
9 | * Exponential
10 | * Log Normal
11 | * Bernoulli
12 | * Normal
13 | * Uniform
14 | '''
15 |
16 | import numpy as np
17 | import math
18 |
19 | class Discrete:
20 | """
21 | Encapsulates a discrete distribution
22 | """
23 | def __init__(self, elements, probabilities, random_seed=None):
24 | self.elements = elements
25 | self.probabilities = probabilities
26 |
27 | self.validate_lengths(elements, probabilities)
28 | self.validate_probs(probabilities)
29 |
30 | self.cum_probs = np.add.accumulate(probabilities)
31 |
32 | self.rng = np.random.default_rng(random_seed)
33 |
34 |
35 | def validate_lengths(self, elements, probs):
36 | if (len(elements) != len(probs)):
37 | raise ValueError('Elements and probilities arguments must be of the same length')
38 |
39 | def validate_probs(self, probs):
40 | if not math.isclose(sum(probs), 1.0):
41 | raise ValueError('Probabilities must sum to 1')
42 |
43 | def sample(self, size=None):
44 | return self.elements[np.digitize(self.rng.random(size), self.cum_probs)]
45 |
46 | class Exponential:
47 | '''
48 | Convenience class for the exponential distribution.
49 | packages up distribution parameters, seed and random generator.
50 | '''
51 | def __init__(self, mean, random_seed=None):
52 | '''
53 | Constructor
54 |
55 | Params:
56 | ------
57 | mean: float
58 | The mean of the exponential distribution
59 |
60 | random_seed: int, optional (default=None)
61 | A random seed to reproduce samples. If set to none then a unique
62 | sample is created.
63 | '''
64 | self.rng = np.random.default_rng(seed=random_seed)
65 | self.mean = mean
66 |
67 | def sample(self, size=None):
68 | '''
69 | Generate a sample from the exponential distribution
70 |
71 | Params:
72 | -------
73 | size: int, optional (default=None)
74 | the number of samples to return. If size=None then a single
75 | sample is returned.
76 | '''
77 | return self.rng.exponential(self.mean, size=size)
78 |
79 |
80 | class Bernoulli:
81 | '''
82 | Convenience class for the Bernoulli distribution.
83 | packages up distribution parameters, seed and random generator.
84 | '''
85 | def __init__(self, p, random_seed=None):
86 | '''
87 | Constructor
88 |
89 | Params:
90 | ------
91 | p: float
92 | probability of drawing a 1
93 |
94 | random_seed: int, optional (default=None)
95 | A random seed to reproduce samples. If set to none then a unique
96 | sample is created.
97 | '''
98 | self.rng = np.random.default_rng(seed=random_seed)
99 | self.p = p
100 |
101 | def sample(self, size=None):
102 | '''
103 | Generate a sample from the exponential distribution
104 |
105 | Params:
106 | -------
107 | size: int, optional (default=None)
108 | the number of samples to return. If size=None then a single
109 | sample is returned.
110 | '''
111 | return self.rng.binomial(n=1, p=self.p, size=size)
112 |
113 | class Lognormal:
114 | """
115 | Encapsulates a lognormal distirbution
116 | """
117 | def __init__(self, mean, stdev, random_seed=None):
118 | """
119 | Params:
120 | -------
121 | mean: float
122 | mean of the lognormal distribution
123 |
124 | stdev: float
125 | standard dev of the lognormal distribution
126 |
127 | random_seed: int, optional (default=None)
128 | Random seed to control sampling
129 | """
130 | self.rng = np.random.default_rng(seed=random_seed)
131 | mu, sigma = self.normal_moments_from_lognormal(mean, stdev**2)
132 | self.mu = mu
133 | self.sigma = sigma
134 |
135 | def normal_moments_from_lognormal(self, m, v):
136 | '''
137 | Returns mu and sigma of normal distribution
138 | underlying a lognormal with mean m and variance v
139 | source: https://blogs.sas.com/content/iml/2014/06/04/simulate-lognormal
140 | -data-with-specified-mean-and-variance.html
141 |
142 | Params:
143 | -------
144 | m: float
145 | mean of lognormal distribution
146 | v: float
147 | variance of lognormal distribution
148 |
149 | Returns:
150 | -------
151 | (float, float)
152 | '''
153 | phi = math.sqrt(v + m**2)
154 | mu = math.log(m**2/phi)
155 | sigma = math.sqrt(math.log(phi**2/m**2))
156 | return mu, sigma
157 |
158 | def sample(self):
159 | """
160 | Sample from the normal distribution
161 | """
162 | return self.rng.lognormal(self.mu, self.sigma)
163 |
164 |
165 | class Normal:
166 | '''
167 | Convenience class for the normal distribution.
168 | packages up distribution parameters, seed and random generator.
169 | '''
170 | def __init__(self, mean, sigma, random_seed=None):
171 | '''
172 | Constructor
173 |
174 | Params:
175 | ------
176 | mean: float
177 | The mean of the normal distribution
178 |
179 | sigma: float
180 | The stdev of the normal distribution
181 |
182 | random_seed: int, optional (default=None)
183 | A random seed to reproduce samples. If set to none then a unique
184 | sample is created.
185 | '''
186 | self.rng = np.random.default_rng(seed=random_seed)
187 | self.mean = mean
188 | self.sigma = sigma
189 |
190 | def sample(self, size=None):
191 | '''
192 | Generate a sample from the normal distribution
193 |
194 | Params:
195 | -------
196 | size: int, optional (default=None)
197 | the number of samples to return. If size=None then a single
198 | sample is returned.
199 | '''
200 | return self.rng.normal(self.mean, self.sigma, size=size)
201 |
202 |
203 | class Uniform:
204 | '''
205 | Convenience class for the Uniform distribution.
206 | packages up distribution parameters, seed and random generator.
207 | '''
208 | def __init__(self, low, high, random_seed=None):
209 | '''
210 | Constructor
211 |
212 | Params:
213 | ------
214 | low: float
215 | lower range of the uniform
216 |
217 | high: float
218 | upper range of the uniform
219 |
220 | random_seed: int, optional (default=None)
221 | A random seed to reproduce samples. If set to none then a unique
222 | sample is created.
223 | '''
224 | self.rand = np.random.default_rng(seed=random_seed)
225 | self.low = low
226 | self.high = high
227 |
228 | def sample(self, size=None):
229 | '''
230 | Generate a sample from the uniform distribution
231 |
232 | Params:
233 | -------
234 | size: int, optional (default=None)
235 | the number of samples to return. If size=None then a single
236 | sample is returned.
237 | '''
238 | return self.rand.uniform(low=self.low, high=self.high, size=size)
239 | # Gamma and empirical from https://github.com/AliHarp/HEP/blob/main/HEP_notebooks/01_model/01_HEP_main.ipynb
240 | class Gamma:
241 | """
242 | sensitivity analysis on LoS distributions for each patient type
243 | """
244 | def __init__(self, mean, stdv, random_seed = None):
245 | self.rng = np.random.default_rng(seed = random_seed)
246 | scale, shape = self.calc_params(mean, stdv)
247 | self.scale = scale
248 | self.shape = shape
249 |
250 | def calc_params(self, mean, stdv):
251 | scale = (stdv **2) / mean
252 | shape = (stdv **2) / (scale **2)
253 | return scale, shape
254 |
255 | def sample(self, size = None):
256 | """
257 | method to generate a sample from the gamma distribution
258 | """
259 | return self.rng.gamma(self.shape, self.scale, size = size)
260 |
261 | class Empirical:
262 | """
263 | for los distributions not fitting statistical distributions
264 | losdata: los per procedure type
265 | """
266 | def __init__(self, losdata, random_seed = None):
267 | self.rng = np.random.default_rng(seed = random_seed)
268 | self.losdata = losdata
269 |
270 | def sample(self, size = None):
271 | """
272 | method to generate a sample from empirical distribution
273 | """
274 | return self.rng.choice(self.losdata, size=None, replace=True)
275 |
276 |
277 | class Poisson:
278 | '''
279 | Convenience class for the poisson distribution.
280 | packages up distribution parameters, seed and random generator.
281 | '''
282 | def __init__(self, mean, random_seed=None):
283 | '''
284 | Constructor
285 |
286 | Params:
287 | ------
288 | mean: float
289 | The mean of the poisson distribution
290 |
291 | random_seed: int, optional (default=None)
292 | A random seed to reproduce samples. If set to none then a unique
293 | sample is created.
294 | '''
295 | self.rand = np.random.default_rng(seed=random_seed)
296 | self.mean = mean
297 |
298 | def sample(self, size=None):
299 | '''
300 | Generate a sample from the poisson distribution
301 |
302 | Params:
303 | -------
304 | size: int, optional (default=None)
305 | the number of samples to return. If size=None then a single
306 | sample is returned.
307 | '''
308 | return self.rand.poisson(self.mean, size=size)
309 |
--------------------------------------------------------------------------------
/notes.md:
--------------------------------------------------------------------------------
1 | # Current mechanism
2 |
3 | In short, the final plot is an animated plotly scatterplot.
4 |
5 | In theory, there's nothing to stop it using an alternative mode of action (e.g. svg), but one benefit of plotly is that it nicely deals with the intermediate paths of patients. It is also available in both Python and R with minimal changes and has extensive compatability with other tools - e.g. Streamlit, Dash.
6 |
7 |
8 | There are a couple of key steps to setting up the visualisation
9 | 1. Adding logging steps to the model
10 | 2. Swapping the use of resources for simpy stores *containing* resources
11 | 3. Creating an object that stores resources - a 'scenario' object - which then informs the number of resources displayed
12 | 4. Iterating through the logs to make a minute-by-minute picture of the position of every patient (or any desired interval)
13 | 5. Using Plotly to display these logs
14 |
15 |
16 | ## 1. Adding logging steps to the model
17 |
18 | Five key classes of events need to be logged for every patient:
19 | - arrival
20 | - queue
21 | - resource use start
22 | - resource use end *(could possibly be removed)*
23 | - depart
24 |
25 | Simple improvements required include applying consistency to naming (e.g. arrival and departure, arrive and depart, not a mixture of the two)
26 |
27 | At present, five to six things are recorded per log. 'Pathway' could potentially be removed.
28 |
29 | This whole structure could be rewritten to be significantly less verbose. It is written like this at present because of the ease of transforming this structure of dictionary to a dataframe and the flexibility of the structure, but exploring alternatives like key:value pairs of event:time could be explored.
30 |
31 |
32 | Currenly, the key logs take the following format
33 |
34 | Arrival:
35 | ```
36 | self.full_event_log.append({
37 | 'patient': self.identifier,
38 | 'pathway': 'Simplest',
39 | 'event_type': 'arrival_departure',
40 | 'event': 'arrival',
41 | 'time': self.env.now
42 | })
43 | ```
44 | Queueing:
45 | ```
46 | self.full_event_log.append({
47 | 'patient': self.identifier,
48 | 'pathway': 'Simplest',
49 | 'event': 'treatment_wait_begins',
50 | 'event_type': 'queue',
51 | 'time': self.env.now
52 | })
53 | ```
54 |
55 | Resource Use Start:
56 | ```
57 | self.full_event_log.append({
58 | 'patient': self.identifier,
59 | 'pathway': 'Simplest',
60 | 'event': 'treatment_begins',
61 | 'event_type': 'resource_use',
62 | 'time': self.env.now,
63 | 'resource_id': treatment_resource.id_attribute
64 | })
65 | ```
66 |
67 | Resource Use End:
68 | ```
69 | self.full_event_log.append({
70 | 'patient': self.identifier,
71 | 'pathway': 'Simplest',
72 | 'event': 'treatment_complete',
73 | 'event_type': 'resource_use_end',
74 | 'time': self.env.now,
75 | 'resource_id': treatment_resource.id_attribute
76 | })
77 | ```
78 |
79 | Departure:
80 | ```
81 | self.full_event_log.append({
82 | 'patient': self.identifier,
83 | 'pathway': 'Simplest',
84 | 'event': 'depart',
85 | 'event_type': 'arrival_departure',
86 | 'time': self.env.now
87 | })
88 | ```
89 |
90 | ## 2. Swapping the use of resources for simpy stores *containing* resources
91 | When a resource is in use, we need to be able to show a single entity consistently hogging the same resource throughout the full time they are using it.
92 |
93 | Simpy resources do not inherently have any ID attribute.
94 | After exploring options like monkey patching the resource class, a better alternative seemed to be using a simpy store - which does have an ID - instead of a straight resource.
95 |
96 | Without this ID attribute, the default logic used to move entities through the steps results in them visually behaving like a queue, which makes it hard to understand how long someone has been using a resource for and is visually confusing.
97 |
98 | Fortunately the code changes required are minimal. We initialise the store, then use a loop to create as many resources within that store as required.
99 |
100 | ```
101 | def init_resources(self):
102 | '''
103 | Init the number of resources
104 | and store in the arguments container object
105 |
106 | Resource list:
107 | 1. Nurses/treatment bays (same thing in this model)
108 |
109 | '''
110 | self.args.treatment = simpy.Store(self.env)
111 |
112 | for i in range(self.args.n_cubicles_1):
113 | self.args.treatment.put(
114 | CustomResource(
115 | self.env,
116 | capacity=1,
117 | id_attribute = i+1)
118 | )
119 | ```
120 |
121 | Use of the resource then becomes
122 |
123 | ```
124 | # Seize a treatment resource when available
125 | treatment_resource = yield self.args.treatment.get()
126 | ```
127 |
128 | When the timeout has elapsed, we then use the following code.
129 |
130 | ```
131 | # Resource is no longer in use, so put it back in
132 | self.args.treatment.put(treatment_resource)
133 | ```
134 |
135 | This has additional benefits of making it easier to monitor the use of individual resources.
136 |
137 | One thing that has been noticed is that the resources seem to be cycled through in order. For example, if you have 4 resources and all are available, but the last resource to be in use was resource 2, resource 3 will be seized the next time someone requires a resource. This may not be entirely realistic, and code to 'shake up' the resources after use may be worth exploring.
138 |
139 | ## 3. Creating an object that stores resources - a 'scenario' object - which then informs the number of resources displayed
140 | At present this part of the code expects a scenario object.
141 | This could be changed to
142 | - expect a dictionary instead
143 | - work with either a scenario or dictionary object (maybe if the route of expanding TM's approach to simpy modelling into an opinionated framework)
144 |
145 | If going with the first option, the scenario class used in TM's work routinely could be expanded to include a method to export the required data to a dictionary format.
146 |
147 | ```{python}
148 | events_with_resources = event_position_df[event_position_df['resource'].notnull()].copy()
149 | events_with_resources['resource_count'] = events_with_resources['resource'].apply(lambda x: getattr(scenario, x))
150 |
151 | events_with_resources = events_with_resources.join(events_with_resources.apply(
152 | lambda r: pd.Series({'x_final': [r['x']-(10*(i+1)) for i in range(r['resource_count'])]}), axis=1).explode('x_final'),
153 | how='right')
154 |
155 | fig.add_trace(go.Scatter(
156 | x=events_with_resources['x_final'].to_list(),
157 | # Place these slightly below the y position for each entity
158 | # that will be using the resource
159 | y=[i-10 for i in events_with_resources['y'].to_list()],
160 | mode="markers",
161 | # Define what the marker will look like
162 | marker=dict(
163 | color='LightSkyBlue',
164 | size=15),
165 | opacity=0.8,
166 | hoverinfo='none'
167 | ))
168 | ```
169 |
170 | ## 4. Iterating through the logs to make a minute-by-minute picture of the position of every patient (or any desired interval)
171 | The function `reshape_for_animations()`
172 |
173 | ## 5. Using Plotly to display these logs
174 |
175 | The function animate_activity_log currently takes 3 mandatory parameters:
176 | - *full_patient_df*
177 | - *event_position_df*
178 | - *scenario*
179 |
180 | *full_patient_df *is the output of the function **reshape_for_animations**
181 |
182 |
183 | The graph is a plotly scatterplot.
184 | The initial animated plot is created using plotly express, with additional static layers added afterwards.
185 |
186 | Each individual is a scatter point. The actual points are fully transparent, and what we see is a text label - the emoji.
187 |
188 | A list of any length of emojis is required. This will then be joined with a distinct patient table to provide a list of patients.
189 |
190 |
191 | # Examples required
192 |
193 | ## Already created
194 | - Simple pathway (units: minutes)
195 | - Pathway with branching and optional steps (units: weeks)
196 |
197 |
198 | ## Not yet created - additional features possibly required
199 | - Simple pathway (units: days, weeks)
200 | - Resource numbers that change at different points of the day
201 | - Prioritised queueing
202 | - Shared resources
203 | - Multiple resources required for a step (e.g. doctor + cubicle - how to display this?)
204 | - Reneging
205 | - Jockeying
206 | - Balking
207 |
208 |
209 |
210 | # Other comments
211 |
212 | ## Known areas for attention
213 | - The code is not written in an object oriented manner.
214 | - There's a bug in the wrapping code that results in queues building out in a diagonal manner (shifted 1 to the left) from the 3rd row onwards (2nd row counts to 11 instead of 10, and then subsequent rows correctly include 10 but start too far over)
215 |
216 | ## Required enhancements
217 | - At present, the queue of users will continue to grow indefinitely until it leaves the boundary.
218 |
219 | ## Friction points
220 | - Setting up the background image can be a fiddly process
221 |
222 |
223 | ## Other limitations
224 | - By avoiding emojis that were released after v12.0 of the emoji standard (released in early 2019), we can ensure compatability with most major OSs. Windows 10 has not been updated past this point. However, due to the nature of emojis, we cannot absolutely ensure full compatability across all systems.
225 |
226 |
227 |
228 | ## Concerns
229 |
230 | - Currently, logging can cope with ~5 minute snapshots for 5 days of logs in a system that has ~10-60 people in the system at any given point in time.
231 | This results in a self-contained plot of ~20mb when exported (for comparison, a self-contained line chart with some additional rectangles is <20kb).
232 | - 5 days was chosen as a good limit for the streamlit teaching app as it offered a good balance between speed and minimized the risk of crashing across different choices of parameters.
233 | - If significantly too few resources are provided at a given step, the size of the animation dataframe quickly gets out of hand (as people aren't getting through the system so the number of people in the system at each snapshot is very large)
234 | - Working on a way of displaying queues after a threshold number of people is reached will help significantly
235 |
236 | # Discussion with Tom
237 |
238 | 1.
239 |
240 | - VIS (visual interactive simulation)
241 |
242 | Sell it as being able to look at the simulation log visually
243 |
244 | - Can be used to describe model logic to a client
245 |
246 | - Can be used for validation (extreme value, model logic)
247 | - e.g. very long process times
248 | - e.g. very low number of resources and high service time
249 |
250 |
251 |
252 | 2. LLM???? Future
253 | Getting prompts to be generated
254 |
255 |
256 |
257 | "Towards visualiation"
258 |
259 | Need
260 |
261 | Journal of simulation
262 |
263 |
264 | Alison + Tom, + Dan + me
265 |
266 |
267 |
268 |
269 |
270 |
271 |
--------------------------------------------------------------------------------
/examples/ex_2_branching_and_optional_paths/simulation_summary_functions.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pandas as pd
3 |
4 | # list of metrics useful for external apps
5 | # RESULT_FIELDS = ['00_arrivals',
6 | # '01a_triage_wait',
7 | # '01b_triage_util',
8 | # '02a_registration_wait',
9 | # '02b_registration_util',
10 | # '03a_examination_wait',
11 | # '03b_examination_util',
12 | # '04a_treatment_wait(non_trauma)',
13 | # '04b_treatment_util(non_trauma)',
14 | # '05_total_time(non-trauma)',
15 | # '06a_trauma_wait',
16 | # '06b_trauma_util',
17 | # '07a_treatment_wait(trauma)',
18 | # '07b_treatment_util(trauma)',
19 | # '08_total_time(trauma)',
20 | # '09_throughput']
21 |
22 | # # list of metrics useful for external apps
23 | # RESULT_LABELS = {'00_arrivals': 'Arrivals',
24 | # '01a_triage_wait': 'Triage Wait (mins)',
25 | # '01b_triage_util': 'Triage Utilisation',
26 | # '02a_registration_wait': 'Registration Waiting Time (mins)',
27 | # '02b_registration_util': 'Registration Utilisation',
28 | # '03a_examination_wait': 'Examination Waiting Time (mins)',
29 | # '03b_examination_util': 'Examination Utilisation',
30 | # '04a_treatment_wait(non_trauma)': 'Non-trauma cubicle waiting time (mins)',
31 | # '04b_treatment_util(non_trauma)': 'Non-trauma cubicle utilisation',
32 | # '05_total_time(non-trauma)': 'Total time (non-trauma)',
33 | # '06a_trauma_wait': 'Trauma stabilisation waiting time (mins)',
34 | # '06b_trauma_util': 'Trauma stabilisation utilisation',
35 | # '07a_treatment_wait(trauma)': 'Trauma cubicle waiting time (mins)',
36 | # '07b_treatment_util(trauma)': 'Trauma cubicle utilisation',
37 | # '08_total_time(trauma)': 'Total time (trauma)',
38 | # '09_throughput': 'throughput'}
39 |
40 |
41 | class SimulationSummary:
42 | '''
43 | End of run result processing logic of the simulation model
44 | '''
45 |
46 | def __init__(self, model):
47 | '''
48 | Constructor
49 |
50 | Params:
51 | ------
52 | model: TraumaCentreModel
53 | The model.
54 | '''
55 | self.model = model
56 | self.args = model.args
57 | self.results = None
58 | self.patient_log = None
59 | self.event_log = model.event_log
60 | self.utilisation_audit = model.utilisation_audit
61 |
62 | def get_mean_metric(self, metric, patients):
63 | '''
64 | Calculate mean of the performance measure for the
65 | select cohort of patients,
66 |
67 | Only calculates metrics for patients where it has been
68 | measured.
69 |
70 | Params:
71 | -------
72 | metric: str
73 | The name of the metric e.g. 'wait_treat'
74 |
75 | patients: list
76 | A list of patients
77 | '''
78 | mean = np.array([getattr(p, metric) for p in patients
79 | if getattr(p, metric) > -np.inf]).mean()
80 | return mean
81 |
82 | def get_perc_wait_target_met(self, metric, patients, target):
83 | '''
84 | Calculate the percentage of patients where a target was met for
85 | the select cohort of patients,
86 |
87 | Only calculates metrics for patients where it has been
88 | measured.
89 |
90 | Params:
91 | -------
92 | metric: str
93 | The name of the metric e.g. 'wait_treat'
94 |
95 | patients: list
96 | A list of patients
97 | '''
98 | met = len(np.array([getattr(p, metric) for p in patients
99 | if getattr(p, metric) < target]))
100 | total = len(np.array([getattr(p, metric) for p in patients
101 | if getattr(p, metric) > -np.inf]))
102 |
103 | return met/total
104 |
105 | def get_resource_util(self, metric, n_resources, patients):
106 | '''
107 | Calculate proportion of the results collection period
108 | where a resource was in use.
109 |
110 | Done by tracking the duration by patient.
111 |
112 | Only calculates metrics for patients where it has been
113 | measured.
114 |
115 | Params:
116 | -------
117 | metric: str
118 | The name of the metric e.g. 'treatment_duration'
119 |
120 | patients: list
121 | A list of patients
122 | '''
123 | total = np.array([getattr(p, metric) for p in patients
124 | if getattr(p, metric) > -np.inf]).sum()
125 |
126 | return total / (self.model.rc_period * n_resources)
127 |
128 | def get_throughput(self, patients):
129 | '''
130 | Returns the total number of patients that have successfully
131 | been processed and discharged in the treatment centre
132 | (they have a total time record)
133 |
134 | Params:
135 | -------
136 | patients: list
137 | list of all patient objects simulated.
138 |
139 | Returns:
140 | ------
141 | float
142 | '''
143 | return len([p for p in patients if p.total_time > -np.inf])
144 |
145 |
146 | def process_run_results(self):
147 | '''
148 | Calculates statistics at end of run.
149 | '''
150 | self.results = {}
151 |
152 | patients = self.model.non_trauma_patients + self.model.trauma_patients
153 |
154 | # self.patient_log = self.model.patients
155 |
156 | self.results = {
157 | '00_arrivals': len(patients),
158 |
159 | '01a_triage_wait': self.get_mean_metric('wait_triage',
160 | patients),
161 | '01b_triage_util': self.get_resource_util('triage_duration',
162 | self.args.n_triage,
163 | patients),
164 | # '01c_triage_wait_target_met': self.get_perc_wait_target_met('wait_triage',
165 | # self.model.patients,
166 | # target=10),
167 |
168 | '02a_reg_wait': self.get_mean_metric('wait_reg',
169 | self.model.non_trauma_patients),
170 | '02b_reg_util': self.get_resource_util('reg_duration',
171 | self.args.n_reg,
172 | self.model.non_trauma_patients),
173 | # '02c_reg_wait_target_met': self.get_perc_wait_target_met('wait_reg',
174 | # self.model.non_trauma_patients,
175 | # target=60),
176 |
177 | '03a_exam_wait': self.get_mean_metric('wait_exam',
178 | self.model.non_trauma_patients),
179 | '03b_exam_util': self.get_resource_util('exam_duration',
180 | self.args.n_exam,
181 | self.model.non_trauma_patients),
182 | # '03c_exam_wait_target_met': self.get_perc_wait_target_met('wait_reg',
183 | # self.model.non_trauma_patients,
184 | # target=60),
185 |
186 | '04a_non_trauma_treat_wait': self.get_mean_metric('wait_treat',
187 | self.model.non_trauma_patients),
188 | '04b_non_trauma_treat_util': self.get_resource_util('treat_duration',
189 | self.args.n_cubicles_1,
190 | self.model.non_trauma_patients),
191 | # '04c_non_trauma_treat_wait_target_met': self.get_perc_wait_target_met('wait_treat',
192 | # self.model.non_trauma_patients,
193 | # target=60),
194 |
195 | '05a_trauma_stabilisation_wait': self.get_mean_metric('wait_treat',
196 | self.model.trauma_patients),
197 | '05b_trauma_stabilisation_util': self.get_resource_util('treat_duration',
198 | self.args.n_trauma,
199 | self.model.trauma_patients),
200 | # '05c_trauma_stabilisation_wait_target_met': self.get_perc_wait_target_met('wait_treat',
201 | # self.model.trauma_patients,
202 | # target=60),
203 |
204 | '06a_trauma_treat_wait': self.get_mean_metric('wait_treat',
205 | self.model.trauma_patients),
206 | '06b_trauma_treat_util': self.get_resource_util('treat_duration',
207 | self.args.n_cubicles_2,
208 | self.model.trauma_patients),
209 | # '06c_trauma_treat_wait_target_met': self.get_perc_wait_target_met('wait_treat',
210 | # self.model.trauma_patients,
211 | # target=60),
212 |
213 | '07_total_time': self.get_mean_metric('total_time', self.model.patients),
214 | '08_throughput': self.get_throughput(self.model.patients)
215 | }
216 |
217 |
218 |
219 | def summary_frame(self):
220 | '''
221 | Returns run results as a pandas.DataFrame
222 |
223 | Returns:
224 | -------
225 | pd.DataFrame
226 | '''
227 | # append to results df
228 | if self.results is None:
229 | self.process_run_results()
230 |
231 | df = pd.DataFrame({'1': self.results})
232 | df = df.T
233 | df.index.name = 'rep'
234 | return df
235 |
236 | def detailed_logs(self):
237 | '''
238 | Returns run results as a pandas.DataFrame
239 |
240 | Returns:
241 | -------
242 | pd.DataFrame
243 | '''
244 | # append to results df
245 | if self.event_log is None:
246 | self.process_run_results()
247 |
248 | return {
249 | 'patient': self.patient_log,
250 | 'event_log': self.event_log,
251 | 'results_summary': self.results
252 | }
253 |
--------------------------------------------------------------------------------
/pages/5_Community_Booking_Model.py:
--------------------------------------------------------------------------------
1 | import gc
2 | import time
3 | import math
4 | import datetime as dt
5 | import streamlit as st
6 | import pandas as pd
7 | import plotly.express as px
8 | import plotly.graph_objects as go
9 | from examples.ex_4_community.model_classes import Scenario, generate_seed_vector
10 | from examples.ex_4_community.simulation_execution_functions import single_run
11 | from examples.ex_4_community.simulation_summary_functions import results_summary
12 | from vidigi.prep import reshape_for_animations, generate_animation_df
13 | from vidigi.animation import generate_animation
14 | # from plotly.subplots import make_subplots
15 |
16 | st.set_page_config(layout="wide",
17 | initial_sidebar_state="expanded",
18 | page_title="Mental Health - Booking Model")
19 |
20 | gc.collect()
21 |
22 | st.title("Mental Health - Appointment Booking Model")
23 |
24 | st.markdown(
25 | """
26 | This model looks at a simple mental health pathway.
27 |
28 | In this model, we are only concerned with the booking of an initial appointment.
29 |
30 | By default, the model uses an appointment book with some slots held back for high-priority patients.
31 | Each patient in the default scenario can only go to their 'home'/most local clinic.
32 |
33 | However, it is possible to switch to other scenarios
34 | - a 'pooling' system where patients can choose between one of several linked clinics in their local area (with the assumption that they will choose the clinic of the group with the soonest available appointment)
35 | - the pooling system described above, but with no slots held back for high-priority patients (i.e. no 'carve-out')
36 | """
37 | )
38 |
39 | # args = Scenario()
40 |
41 | #example solution...
42 |
43 | st.subheader("Weekly Slots")
44 | st.markdown("Edit the number of daily slots available per clinic by clicking in the boxes below, or leave as the default schedule")
45 | shifts = pd.read_csv("examples/ex_4_community/data/shifts.csv")
46 | shifts_edited = st.data_editor(shifts)
47 |
48 | scenario_choice = st.selectbox(
49 | 'Choose a Scenario',
50 | ('As-is', 'With Pooling', 'With Pooling - No Carve-out'))
51 |
52 | if scenario_choice == "As-is" or scenario_choice == "With Pooling":
53 | prop_carve_out = st.slider("Select proportion of carve-out", 0.0, 0.9, 0.15, 0.01)
54 | else:
55 | prop_carve_out = 0.0
56 |
57 | #depending on settings and CPU this model takes around 15-20 seconds to run
58 |
59 | button_run_pressed = st.button("Run simulation")
60 |
61 | if button_run_pressed:
62 |
63 | # add a spinner and then display success box
64 | with st.spinner('Simulating the community booking system...'):
65 |
66 | RESULTS_COLLECTION = 365 * 1
67 |
68 | #We will learn about warm-up periods in a later lab. We use one
69 | #because the model starts up empty which doesn't reflect reality
70 | WARM_UP = 365 * 1
71 | RUN_LENGTH = RESULTS_COLLECTION + WARM_UP
72 |
73 | #set up the scenario for the model to run.
74 | scenarios = {}
75 |
76 | scenarios['as-is'] = Scenario(RUN_LENGTH,
77 | WARM_UP,
78 | prop_carve_out=prop_carve_out,
79 | seeds=generate_seed_vector(),
80 | slots_file=shifts_edited)
81 |
82 | scenarios['pooled'] = Scenario(RUN_LENGTH,
83 | WARM_UP,
84 | prop_carve_out=prop_carve_out,
85 | pooling=True,
86 | seeds=generate_seed_vector(),
87 | slots_file=shifts_edited)
88 |
89 | scenarios['no_carve_out'] = Scenario(RUN_LENGTH,
90 | WARM_UP,
91 | pooling=True,
92 | prop_carve_out=0.0,
93 | seeds=generate_seed_vector(),
94 | slots_file=shifts_edited)
95 |
96 | col1, col2, col3 = st.columns(3)
97 |
98 | if scenario_choice == "As-is":
99 | st.subheader("As-is")
100 | results_all, results_low, results_high, event_log = single_run(scenarios['as-is'])
101 | st.dataframe(results_summary(results_all, results_low, results_high))
102 | elif scenario_choice == "With Pooling":
103 | st.subheader("With Pooling")
104 | results_all, results_low, results_high, event_log = single_run(scenarios['pooled'])
105 | st.dataframe(results_summary(results_all, results_low, results_high))
106 |
107 | elif scenario_choice == "With Pooling - No Carve-out":
108 | st.subheader("Pooled with no carve out")
109 | results_all, results_low, results_high, event_log = single_run(scenarios['no_carve_out'])
110 | st.dataframe(results_summary(results_all, results_low, results_high))
111 |
112 |
113 | event_log_df = pd.DataFrame(event_log)
114 |
115 |
116 | event_log_df['event_original'] = event_log_df['event']
117 | event_log_df['event'] = event_log_df.apply(lambda x: f"{x['event']}{f'_{int(x.booked_clinic)}' if pd.notna(x['booked_clinic']) else ''}", axis=1)
118 |
119 | full_patient_df = reshape_for_animations(event_log_df,
120 | entity_col_name="patient",
121 | limit_duration=WARM_UP+180,
122 | every_x_time_units=1,
123 | step_snapshot_max=50)
124 |
125 | # Remove the warm-up period from the event log
126 | full_patient_df = full_patient_df[full_patient_df["snapshot_time"] >= WARM_UP]
127 |
128 |
129 | clinics = [x for x in event_log_df['booked_clinic'].sort_values().unique().tolist() if not math.isnan(x)]
130 |
131 | clinic_waits = [{'event': f'appointment_booked_waiting_{int(clinic)}',
132 | 'y': 950-(clinic+1)*80,
133 | 'x': 625,
134 | 'label': f"Booked into clinic {int(clinic)}",
135 | 'clinic': int(clinic)}
136 | for clinic in clinics]
137 |
138 | clinic_attends = [{'event': f'have_appointment_{int(clinic)}',
139 | 'y': 950-(clinic+1)*80,
140 | 'x': 850,
141 | 'label': f"Attending appointment at clinic {int(clinic)}"}
142 | for clinic in clinics]
143 |
144 | event_position_df = pd.concat([pd.DataFrame(clinic_waits),(pd.DataFrame(clinic_attends))])
145 |
146 | referred_out = [{'event': f'referred_out_{int(clinic)}',
147 | 'y': 950-(clinic+1)*80,
148 | 'x': 125,
149 | 'label': f"Referred Out From clinic {int(clinic)}"}
150 | for clinic in clinics]
151 |
152 | event_position_df = pd.concat([event_position_df,(pd.DataFrame(referred_out))])
153 |
154 | # event_position_df = pd.concat([
155 | # event_position_df,
156 | # pd.DataFrame([{'event': 'exit', 'x': 270, 'y': 70, 'label': "Exit"}])]) .reset_index(drop=True)
157 |
158 | clinic_lkup_df = pd.DataFrame([
159 | {'clinic': 0, 'icon': "🟠"},
160 | {'clinic': 1, 'icon': "🟡"},
161 | {'clinic': 2, 'icon': "🟢"},
162 | {'clinic': 3, 'icon': "🔵"},
163 | {'clinic': 4, 'icon': "🟣"},
164 | {'clinic': 5, 'icon': "🟤"},
165 | {'clinic': 6, 'icon': "⚫"},
166 | {'clinic': 7, 'icon': "⚪"},
167 | {'clinic': 8, 'icon': "🔶"},
168 | {'clinic': 9, 'icon': "🔷"},
169 | {'clinic': 10, 'icon': "🟩"}
170 | ])
171 |
172 |
173 | if scenario_choice == "With Pooling" or scenario_choice == "With Pooling - No Carve-out":
174 | event_position_df = event_position_df.merge(clinic_lkup_df, how="left")
175 | event_position_df["label"] = event_position_df.apply(lambda x: f"{x['label']} {x['icon']}" if pd.notna(x['icon']) else x['label'], axis=1)
176 | event_position_df = event_position_df.drop(columns="icon")
177 |
178 | event_position_df = event_position_df.drop(columns="clinic")
179 |
180 | full_patient_df_plus_pos = generate_animation_df(
181 | full_entity_df=full_patient_df,
182 | event_position_df=event_position_df,
183 | entity_col_name="patient",
184 | wrap_queues_at=25,
185 | step_snapshot_max=50,
186 | gap_between_entities=15,
187 | gap_between_resources=15,
188 | gap_between_queue_rows=15,
189 | debug_mode=True
190 | )
191 |
192 |
193 |
194 | if scenario_choice == "With Pooling" or scenario_choice == "With Pooling - No Carve-out":
195 | def show_home_clinic(row):
196 | if "more" not in row["icon"]:
197 | if row["home_clinic"] == 0:
198 | return "🟠"
199 | if row["home_clinic"] == 1:
200 | return "🟡"
201 | if row["home_clinic"] == 2:
202 | return "🟢"
203 | if row["home_clinic"] == 3:
204 | return "🔵"
205 | if row["home_clinic"] == 4:
206 | return "🟣"
207 | if row["home_clinic"] == 5:
208 | return "🟤"
209 | if row["home_clinic"] == 6:
210 | return "⚫"
211 | if row["home_clinic"] == 7:
212 | return "⚪"
213 | if row["home_clinic"] == 8:
214 | return "🔶"
215 | if row["home_clinic"] == 9:
216 | return "🔷"
217 | if row["home_clinic"] == 10:
218 | return "🟩"
219 | else:
220 | return row["icon"]
221 | else:
222 | return row["icon"]
223 |
224 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(icon=full_patient_df_plus_pos.apply(show_home_clinic, axis=1))
225 |
226 |
227 | def show_priority_icon(row):
228 | if "more" not in row["icon"]:
229 | if row["pathway"] == 2:
230 | if scenario_choice == "As-is":
231 | return "🚨"
232 | else:
233 | return f"{row['icon']}*"
234 | else:
235 | return row["icon"]
236 | else:
237 | return row["icon"]
238 |
239 | def add_los_to_icon(row):
240 | if row["event_original"] == "have_appointment":
241 | return f'{row["icon"]} {int(row["wait"])}'
242 | else:
243 | return row["icon"]
244 |
245 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
246 | icon=full_patient_df_plus_pos.apply(show_priority_icon, axis=1)
247 | )
248 |
249 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
250 | icon=full_patient_df_plus_pos.apply(add_los_to_icon, axis=1)
251 | )
252 |
253 | fig = generate_animation(
254 | full_entity_df_plus_pos=full_patient_df_plus_pos,
255 | event_position_df=event_position_df,
256 | scenario=None,
257 | plotly_height=850,
258 | plotly_width=1100,
259 | override_x_max=1000,
260 | override_y_max=1000,
261 | text_size=10,
262 | entity_icon_size=10,
263 | entity_col_name="patient",
264 | include_play_button=True,
265 | add_background_image=None,
266 | display_stage_labels=True,
267 | time_display_units="d",
268 | simulation_time_unit="days",
269 | start_date="2022-06-27",
270 | setup_mode=False,
271 | frame_duration=1500, #milliseconds
272 | frame_transition_duration=1000, #milliseconds
273 | debug_mode=False
274 | )
275 |
276 | st.plotly_chart(fig)
277 |
278 | # fig.show()
279 |
280 | #TODO
281 | # Add in additional trace that shows the number of available slots per day
282 | # using the slot df
283 |
284 | #TODO
285 | # Pooled booking version where being in non-home clinic makes you one colour
286 | # and home clinic makes you another
287 |
288 | #TODO
289 | # Investigate adding a priority attribute to event log
290 | # that can be considered when ranking queues if present
291 |
--------------------------------------------------------------------------------
/examples/ex_1_simplest_case/model_classes.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # coding: utf-8
3 | '''
4 | patient arrives at the treatment centre, is seen, and then leaves
5 | '''
6 | import itertools
7 | import numpy as np
8 | import pandas as pd
9 | import simpy
10 |
11 | from examples.distribution_classes import Exponential, Lognormal
12 | from examples.simulation_utility_functions import trace, CustomResource
13 |
14 | # Simulation model run settings
15 |
16 | class Scenario:
17 | '''
18 | Container class for scenario parameters/arguments
19 |
20 | Passed to a model and its process classes
21 | '''
22 |
23 | def __init__(self,
24 | random_number_set=42,
25 | n_streams = 20,
26 |
27 | n_cubicles_1=2,
28 |
29 | trauma_treat_mean=30,
30 | trauma_treat_var=5,
31 |
32 | manual_arrival_rate=2
33 |
34 | ):
35 | '''
36 | Create a scenario to parameterise the simulation model
37 |
38 | Parameters:
39 | -----------
40 | random_number_set: int, optional (default=DEFAULT_RNG_SET)
41 | Set to control the initial seeds of each stream of pseudo
42 | random numbers used in the model.
43 |
44 | n_cubicles_1: int
45 | The number of treatment cubicles
46 |
47 | trauma_treat_mean: float
48 | Mean of the trauma cubicle treatment distribution (Lognormal)
49 |
50 | trauma_treat_var: float
51 | Variance of the trauma cubicle treatment distribution (Lognormal)
52 |
53 | manual_arrival_rate: float
54 | Set the mean of the exponential distribution that is used to sample the
55 | inter-arrival time of patients
56 |
57 |
58 | '''
59 | # sampling
60 | self.random_number_set = random_number_set
61 | self.n_streams = n_streams
62 |
63 | # store parameters for sampling
64 |
65 | self.trauma_treat_mean = trauma_treat_mean
66 | self.trauma_treat_var = trauma_treat_var
67 |
68 | self.manual_arrival_rate = manual_arrival_rate
69 |
70 |
71 | self.init_sampling()
72 |
73 | # count of each type of resource
74 | self.init_resource_counts(n_cubicles_1)
75 |
76 | def set_random_no_set(self, random_number_set):
77 | '''
78 | Controls the random sampling
79 | Parameters:
80 | ----------
81 | random_number_set: int
82 | Used to control the set of psuedo random numbers
83 | used by the distributions in the simulation.
84 | '''
85 | self.random_number_set = random_number_set
86 | self.init_sampling()
87 |
88 | def init_resource_counts(self, n_cubicles_1):
89 | '''
90 | Init the counts of resources to default values...
91 | '''
92 | self.n_cubicles_1 = n_cubicles_1
93 |
94 | def init_sampling(self):
95 | '''
96 | Create the distributions used by the model and initialise
97 | the random seeds of each.
98 | '''
99 | # create random number streams
100 | rng_streams = np.random.default_rng(self.random_number_set)
101 | self.seeds = rng_streams.integers(0, 999999999, size=self.n_streams)
102 |
103 | # create distributions
104 | # treatment of trauma patients
105 | self.treat_dist = Lognormal(self.trauma_treat_mean,
106 | np.sqrt(self.trauma_treat_var),
107 | random_seed=self.seeds[5])
108 |
109 | self.arrival_dist = Exponential(self.manual_arrival_rate, # pylint: disable=attribute-defined-outside-init
110 | random_seed=self.seeds[8])
111 |
112 | # ## Patient Pathways Process Logic
113 |
114 | class SimplePathway(object):
115 | '''
116 | Encapsulates the process for a patient with minor injuries and illness.
117 |
118 | These patients are arrived, then seen and treated by a nurse as soon as one is available.
119 | No place-based resources are considered in this pathway.
120 |
121 | Following treatment they are discharged.
122 | '''
123 |
124 | def __init__(self, identifier, env, args, event_log):
125 | '''
126 | Constructor method
127 |
128 | Params:
129 | -----
130 | identifier: int
131 | a numeric identifier for the patient.
132 |
133 | env: simpy.Environment
134 | the simulation environment
135 |
136 | args: Scenario
137 | Container class for the simulation parameters
138 |
139 | '''
140 | self.identifier = identifier
141 | self.env = env
142 | self.args = args
143 | self.event_log = event_log
144 |
145 | # metrics
146 | self.arrival = -np.inf
147 | self.wait_treat = -np.inf
148 | self.total_time = -np.inf
149 |
150 | self.treat_duration = -np.inf
151 |
152 | def execute(self):
153 | '''
154 | simulates the simplest minor treatment process for a patient
155 |
156 | 1. Arrive
157 | 2. Examined/treated by nurse when one available
158 | 3. Discharged
159 | '''
160 | # record the time of arrival and entered the triage queue
161 | self.arrival = self.env.now
162 | self.event_log.append(
163 | {'patient': self.identifier,
164 | 'pathway': 'Simplest',
165 | 'event_type': 'arrival_departure',
166 | 'event': 'arrival',
167 | 'time': self.env.now}
168 | )
169 |
170 | # request examination resource
171 | start_wait = self.env.now
172 | self.event_log.append(
173 | {'patient': self.identifier,
174 | 'pathway': 'Simplest',
175 | 'event': 'treatment_wait_begins',
176 | 'event_type': 'queue',
177 | 'time': self.env.now}
178 | )
179 |
180 | # Seize a treatment resource when available
181 | treatment_resource = yield self.args.treatment.get()
182 |
183 | # record the waiting time for registration
184 | self.wait_treat = self.env.now - start_wait
185 | self.event_log.append(
186 | {'patient': self.identifier,
187 | 'pathway': 'Simplest',
188 | 'event': 'treatment_begins',
189 | 'event_type': 'resource_use',
190 | 'time': self.env.now,
191 | 'resource_id': treatment_resource.id_attribute
192 | }
193 | )
194 |
195 | # sample treatment duration
196 | self.treat_duration = self.args.treat_dist.sample()
197 | yield self.env.timeout(self.treat_duration)
198 |
199 | self.event_log.append(
200 | {'patient': self.identifier,
201 | 'pathway': 'Simplest',
202 | 'event': 'treatment_complete',
203 | 'event_type': 'resource_use_end',
204 | 'time': self.env.now,
205 | 'resource_id': treatment_resource.id_attribute}
206 | )
207 |
208 | # Resource is no longer in use, so put it back in
209 | self.args.treatment.put(treatment_resource)
210 |
211 | # total time in system
212 | self.total_time = self.env.now - self.arrival
213 | self.event_log.append(
214 | {'patient': self.identifier,
215 | 'pathway': 'Simplest',
216 | 'event': 'depart',
217 | 'event_type': 'arrival_departure',
218 | 'time': self.env.now}
219 | )
220 |
221 |
222 |
223 | class TreatmentCentreModelSimpleNurseStepOnly:
224 | '''
225 | The treatment centre model
226 |
227 | Patients arrive at random to a treatment centre, see a nurse, then leave.
228 |
229 | The main class that a user interacts with to run the model is
230 | `TreatmentCentreModel`. This implements a `.run()` method, contains a simple
231 | algorithm for the non-stationary poission process for patients arrivals and
232 | inits instances of the nurse pathway.
233 |
234 | '''
235 |
236 | def __init__(self, args):
237 | self.env = simpy.Environment()
238 | self.args = args
239 | self.init_resources()
240 |
241 | self.patients = []
242 |
243 | self.rc_period = None
244 | self.results = None
245 |
246 | self.event_log = []
247 | self.utilisation_audit = []
248 |
249 | def init_resources(self):
250 | '''
251 | Init the number of resources
252 | and store in the arguments container object
253 |
254 | Resource list:
255 | 1. Nurses/treatment bays (same thing in this model)
256 |
257 | '''
258 | self.args.treatment = simpy.Store(self.env)
259 |
260 | for i in range(self.args.n_cubicles_1):
261 | self.args.treatment.put(
262 | CustomResource(
263 | self.env,
264 | capacity=1,
265 | id_attribute = i+1)
266 | )
267 |
268 |
269 |
270 | def run(self, results_collection_period=60*24*10):
271 | '''
272 | Conduct a single run of the model in its current
273 | configuration
274 |
275 |
276 | Parameters:
277 | ----------
278 | results_collection_period, float, optional
279 | default = DEFAULT_RESULTS_COLLECTION_PERIOD
280 |
281 | warm_up, float, optional (default=0)
282 |
283 | length of initial transient period to truncate
284 | from results.
285 |
286 | Returns:
287 | --------
288 | None
289 | '''
290 | # setup the arrival generator process
291 | self.env.process(self.arrivals_generator())
292 |
293 | # store rc perio
294 | self.rc_period = results_collection_period
295 |
296 | # run
297 | self.env.run(until=results_collection_period)
298 |
299 | def interval_audit_utilisation(self, resources, interval=1):
300 | '''
301 | Record utilisation at defined intervals.
302 |
303 | Needs to be passed to env.process when running model
304 |
305 | Parameters:
306 | ------
307 | resource: SimPy resource object
308 | The resource to monitor
309 | OR
310 | a list of dictionaries containing simpy resource objects in the format
311 | [{'resource_name':'my_resource', 'resource_object': resource}]
312 |
313 | interval: int:
314 | Time between audits.
315 | 1 unit of time is 1 day in this model.
316 | '''
317 |
318 | while True:
319 | # Record time
320 | if isinstance(resources, list):
321 | for i in range(len(resources)):
322 | self.utilisation_audit.append({
323 | 'resource_name': resources[i]['resource_name'],
324 | 'simulation_time': self.env.now, # The current simulation time
325 | # The number of users
326 | 'number_utilised': resources[i]['resource_object'].count,
327 | 'number_available': resources[i]['resource_object'].capacity,
328 | # The number of queued processes
329 | 'number_queued': len(resources[i]['resource_object'].queue),
330 | })
331 |
332 | else:
333 | self.utilisation_audit.append({
334 | # 'simulation_time': resource._env.now,
335 | 'simulation_time': self.env.now, # The current simulation time
336 | 'number_utilised': resources.count, # The number of users
337 | 'number_available': resources.capacity,
338 | # The number of queued processes
339 | 'number_queued': len(resources.queue),
340 | })
341 |
342 | # Trigger next audit after interval
343 | yield self.env.timeout(interval)
344 |
345 | def arrivals_generator(self):
346 | '''
347 | Simulate the arrival of patients to the model
348 |
349 | Patients follow the SimplePathway process.
350 |
351 | Non stationary arrivals implemented via Thinning acceptance-rejection
352 | algorithm.
353 | '''
354 | for patient_count in itertools.count():
355 |
356 | interarrival_time = 0.0
357 |
358 | interarrival_time += self.args.arrival_dist.sample()
359 |
360 | yield self.env.timeout(interarrival_time)
361 |
362 | trace(f'patient {patient_count} arrives at: {self.env.now:.3f}')
363 |
364 | # Generate the patient
365 | new_patient = SimplePathway(patient_count, self.env, self.args, self.event_log)
366 | self.patients.append(new_patient)
367 | # start the pathway process for the patient
368 | self.env.process(new_patient.execute())
369 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ***WORK IN PROGRESS***
2 |
3 | This repository contains a series of examples discrete event simulation models with the appropriate logging added to allow visualisation.
4 |
5 | There is then a Streamlit app at [https://simpy-visualisation.streamlit.app/](https://simpy-visualisation.streamlit.app/) demonstrating the animations.
6 |
7 | You can now install the [vidigi](https://pypi.org/project/vidigi/) package to access the relevant animation functions yourself.
8 |
9 | More details about vidigi can be found [in its repository](https://github.com/bergam0t/vidigi) or in [its documentation](https://bergam0t.github.io/vidigi/vidigi_docs/).
10 |
11 | ---
12 |
13 | # Introduction
14 |
15 | Visual display of the outputs of discrete event simulations in simpy have been identified as one of the limitations of simpy, potentially hindering adoption of FOSS simulation in comparison to commercial modelling offerings or GUI FOSS alternatives such as JaamSim.
16 |
17 | > When compared to commercial DES software packages that are commonly used in health research, such as Simul8, or AnyLogic, a limitation of our approach is that we do not display a dynamic patient pathway or queuing network that updates as the model runs a single replication. This is termed Visual Interactive Simulation (VIS) and can help users understand where process problems and delays occur in a patient pathway; albeit with the caveat that single replications can be outliers. A potential FOSS solution compatible with a browser-based app could use a Python package that can represent a queuing network, such as NetworkX, and displaying results via matplotlib. If sophisticated VIS is essential for a FOSS model then researchers may need to look outside of web apps; for example, salabim provides a powerful FOSS solution for custom animation of DES models.
18 | > - Monks T and Harper A. Improving the usability of open health service delivery simulation models using Python and web apps [version 2; peer review: 3 approved]. NIHR Open Res 2023, 3:48 (https://doi.org/10.3310/nihropenres.13467.2)
19 |
20 |
21 | This repository contains code allowing visually appealing, flexible visualisations of discrete event simulations to be created from simpy models, such as the example below:
22 |
23 | Plotly is leveraged to create the final animation, meaning that users can benefit from the ability to further customise or extend the plotly plot, as well as easily integrating with web frameworks such as Streamlit, Dash or Shiny for Python.
24 |
25 | The code has been designed to be flexible and could potentially be used with alternative simulation packages such as ciw or simmer if it is possible to provide all of the required details in the logs that are output.
26 |
27 | To develop and demonstrate the concept, it has so far been used to incorporate visualisation into several existing simpy models that were not initially designed with this sort of visualisation in mind:
28 | - **a minor injuries unit**, showing the utility of the model at high resolutions with branching pathways and the ability to add in a custom background to clearly demarcate process steps
29 |
30 | https://github.com/hsma-programme/Teaching_DES_Concepts_Streamlit/assets/29951987/1adc36a0-7bc0-4808-8d71-2d253a855b31
31 |
32 | - **an elective surgical pathway** (with a focus on cancelled theatre slots due to bed unavailability in recovery areas), with length of stay displayed as well as additional text and graphical data
33 |
34 | https://github.com/Bergam0t/simpy_visualisation/assets/29951987/12e5cf33-7ce3-4f76-b621-62ab49903113
35 |
36 | - **a community mental health assessment pathway**, showing the wait to an appointment as well as highlighting 'urgent' patients with a different icon and showing the time from referral to appointment below the patient icons when they attend the appointment.
37 |
38 | https://github.com/Bergam0t/simpy_visualisation/assets/29951987/80467f76-90c2-43db-bf44-41ec8f4d3abd
39 |
40 | - **a community mental health assessment pathway with pooling of clinics**, showing the 'home' clinic for clients via icon so the balance between 'home' and 'other' clients can be explored.
41 |
42 | https://github.com/Bergam0t/simpy_visualisation/assets/29951987/9f1378f3-1688-4fc1-8603-bd75cfc990fb
43 |
44 | - **a community mental health assessment and treatment pathway**, showing the movement of clients between a wait list, a booking list, and returning for repeat appointments over a period of time while sitting on a caseload in between.
45 |
46 | https://github.com/Bergam0t/simpy_visualisation/assets/29951987/1cfe48cf-310d-4dc0-bfc2-3c2185e02f0f
47 |
48 | # Creating a visualisation from an existing model
49 |
50 | Two key things need to happen to existing models to work with the visualisation code:
51 | 1. All simpy resources need to be changed to simpy stores containing a custom resource with an ID attribute
52 | 2. Logging needs to be added at key points: **arrival, (queueing, resource use start, resource use end), departure**
53 | where the steps in the middle can be repeated for as many queues and resource types as required
54 |
55 | ## 1. All simpy resources need to be changed to simpy stores containing a custom resource with an ID attribute
56 |
57 | To allow the use of resources to be visualised correctly - with entities staying with the same resource throughout the time they are using it - it is essential to be able to identify and track individual resources.
58 |
59 | By default, this is not possible with Simpy resources. They have no ID attribute or similar.
60 |
61 | The easiest workaround which drops fairly painlessly into existing models is to use a simpy store with a custom resource class.
62 |
63 | The custom resource is setup as follows:
64 |
65 | ```{python}
66 | class CustomResource(simpy.Resource):
67 | def __init__(self, env, capacity, id_attribute=None):
68 | super().__init__(env, capacity)
69 | self.id_attribute = id_attribute
70 |
71 | def request(self, *args, **kwargs):
72 | # Add logic to handle the ID attribute when a request is made
73 | return super().request(*args, **kwargs)
74 |
75 | def release(self, *args, **kwargs):
76 | # Add logic to handle the ID attribute when a release is made
77 | return super().release(*args, **kwargs)
78 | ```
79 |
80 | The creation of simpy resources is then replaced with the following pattern:
81 | ```{python}
82 | beds = simpy.Store(environment)
83 |
84 | for i in range(number_of_beds):
85 | beds.put(
86 | CustomResource(
87 | environment,
88 | capacity=1,
89 | id_attribute=i+1)
90 | )
91 | ```
92 |
93 | Instead of requesting a resource in the standard way, you instead use the .get() method.
94 |
95 | ```{python}
96 | req = beds.get()
97 | ```
98 | or
99 | ```{python}
100 | with beds.get() as req:
101 | ...CODE HERE THAT USES THE RESOURCE...
102 | ```
103 |
104 | At the end, it is important to put the resource back into the store, even if you used the 'with' notation, so it can be made available to the next requester:
105 | ```{python}
106 | beds.put(req)
107 | ```
108 | This becomes slightly more complex with conditional requesting (for example, where a resource request is made but if it cannot be fulfilled in time, the requester will renege). This is demonstrated in example 3.
109 |
110 | The benefit of this is that when we are logging, we can use the `.id_attribute` attribute of the custom resource to record the resource that was in use.
111 | This can have wider benefits for monitoring individual resource utilisation within your model as well.
112 |
113 | ## 2. Logging needs to be added at key points
114 |
115 | The animation function needs to be passed an event log with the following layout:
116 |
117 | | patient | pathway | event_type | event | time | resource_id |
118 | |---------|----------|-------------------|--------------------------|------|-------------|
119 | | 15 | Primary | arrival_departure | arrival | 1.22 | |
120 | | 15 | Primary | queue | enter_queue_for_bed | 1.35 | |
121 | | 27 | Revision | arrival_departure | arrival | 1.47 | |
122 | | 27 | Revision | queue | enter_queue_for_bed | 1.58 | |
123 | | 12 | Primary | resource_use_end | post_surgery_stay_ends | 1.9 | 4 |
124 | | 15 | Revision | resource_use | post_survery_stay_begins | 1.9 | 4 |
125 |
126 | One easy way to achieve this is by appending dictionaries to a list at each important point in the process.
127 | For example:
128 |
129 | ```{python}
130 | event_log = []
131 | ...
132 | ...
133 | event_log.append(
134 | {'patient': id,
135 | 'pathway': 'Revision',
136 | 'event_type': 'resource_use',
137 | 'event': 'post_surgery_stay_begins',
138 | 'time': self.env.now,
139 | 'resource_id': bed.id_attribute}
140 | )
141 | ```
142 |
143 | The list of dictionaries can then be converted to a panadas dataframe using
144 | ```{python}
145 | pd.DataFrame(event_log)
146 | ```
147 | and passed to the animation function where required.
148 |
149 | ### Event types
150 |
151 | Four event types are supported in the model: 'arrival_departure', 'resource_use', 'resource_use_end', and 'queue'.
152 |
153 | As a minimum, you will require the use of 'arrival_departure' events and one of
154 | - 'resource_use'/'resource_use_end'
155 | - OR 'queue'
156 |
157 | You can also use both 'resource_use' and 'queue' within the same model very effectively (see `ex_1_simplest_case`, `ex_2_branching_and_optional_paths`, and `ex_3_theatres_beds`).
158 |
159 | #### arrival_departure
160 |
161 | Within this, a minimum of two 'arrival_departure' events per entity are mandatory - `arrival` and `depart`, both with an event_type of `arrival_departure`, as shown below.
162 |
163 | ```{python}
164 | event_log.append(
165 | {'patient': unique_entity_identifier,
166 | 'pathway': 'Revision',
167 | 'event_type': 'arrival_departure',
168 | 'event': 'arrival',
169 | 'time': env.now}
170 | )
171 | ```
172 |
173 | ```{python}
174 | event_log.append(
175 | {'patient': unique_entity_identifier,
176 | 'pathway': 'Revision',
177 | 'event_type': 'arrival_departure',
178 | 'event': 'depart',
179 | 'time': env.now}
180 | )
181 | ```
182 | These are critical as they are used to determine when patients should first and last appear in the model.
183 | Forgetting to include a departure step for all types of patients can lead to slow model performance as the size of the event logs for individual moments will continue to increase indefinitely.
184 |
185 | ### queue
186 |
187 | Queues are key steps in the model.
188 |
189 | `ex_4_community` and `ex_5_community_follow_up` are examples of models without a step where a simpy resource is used, instead using a booking calendar that determines the time that will elapse between stages for entities.
190 |
191 | By tracking each important step in the process as a 'queue' step, the movement of patients can be accurately tracked.
192 |
193 | Patients will be ordered by the point at which they are added to the queue, with the first entries appearing at the front (bottom-right) of the queue.
194 |
195 | ```{python}
196 | event_log.append(
197 | {'patient': unique_entity_identifier,
198 | 'pathway': 'High intensity',
199 | 'event_type': 'queue',
200 | 'event': 'appointment_booked_waiting',
201 | 'time': self.env.now
202 | }
203 | )
204 | ```
205 |
206 | While the keys shown above are mandatory, you can add as many additional keys to a step's log as desired. This can allow you to flexibly make use of the event log for other purposes as well as the animation.
207 |
208 | ### resource_use and resource_use_end
209 |
210 | Resource use is more complex to include but comes with two key benefits over the queue:
211 | - it becomes easier to monitor the length of time a resource is in use by a single entity as users won't 'move through' the resource use stage (which can also prove confusing to less experienced viewers)
212 | - it becomes possible to show the total number of resources that are available, making it easier to understand how well resources are being utilised at different stages
213 |
214 | ```{python}
215 | class CustomResource(simpy.Resource):
216 | def __init__(self, env, capacity, id_attribute=None):
217 | super().__init__(env, capacity)
218 | self.id_attribute = id_attribute
219 |
220 | def request(self, *args, **kwargs):
221 | # Add logic to handle the ID attribute when a request is made
222 | # For example, you can assign an ID to the requester
223 | # self.id_attribute = assign_id_logic()
224 | return super().request(*args, **kwargs)
225 |
226 | def release(self, *args, **kwargs):
227 | # Add logic to handle the ID attribute when a release is made
228 | # For example, you can reset the ID attribute
229 | # reset_id_logic(self.id_attribute)
230 | return super().release(*args, **kwargs)
231 |
232 | triage = simpy.Store(self.env)
233 |
234 | for i in range(n_triage):
235 | triage.put(
236 | CustomResource(
237 | env,
238 | capacity=1,
239 | id_attribute = i+1)
240 | )
241 |
242 | # request sign-in/triage
243 | triage_resource = yield triage.get()
244 |
245 | event_log.append(
246 | {'patient': unique_entity_identifier,
247 | 'pathway': 'Trauma',
248 | 'event_type': 'resource_use',
249 | 'event': 'triage_begins',
250 | 'time': env.now,
251 | 'resource_id': triage_resource.id_attribute
252 | }
253 | )
254 |
255 | yield self.env.timeout(1)
256 |
257 | event_log.append(
258 | {'patient': unique_entity_identifier,
259 | 'pathway': 'Trauma',
260 | 'event_type': 'resource_use_end',
261 | 'event': 'triage_complete',
262 | 'time': env.now,
263 | 'resource_id': triage_resource.id_attribute}
264 | )
265 |
266 | # Resource is no longer in use, so put it back in the store
267 | triage.put(triage_resource)
268 | ```
269 | When providing your event position details, it then just requires you to include an identifier for the resource.
270 |
271 | NOTE: At present this requires you to be using an object to manage your resources. This requirement is planned to be removed in a future version of the work, allowing more flexibility.
272 |
273 | ```{python}
274 | {'event': 'TRAUMA_stabilisation_begins',
275 | 'x': 300, 'y': 500, 'resource':'n_trauma', 'label': "Being Stabilised" }
276 | ```
277 |
278 | # Creating the animation
279 |
280 | ## Determining event positioning in the animation
281 | Once the event log has been created, the positions of each queue and resource must be set up.
282 |
283 | An easy way to create this is passing a list of dictionaries to the `pd.DataFrame` function.
284 |
285 | The columns required are
286 | `event`: This must match the label used for the event in the event log
287 | `x`: The x coordinate of the event for the animation. This will correspond to the bottom-right hand corner of a queue, or the rightmost resource.
288 | `y`: The y coordinate of the event for the animaation. This will correspond to the lowest row of a queue, or the central point of the resources.
289 | `label`: A label for the stage. This can be hidden at a later step if you opt to use a background image with labels built-in. Note that line breaks in the label can be created using the HTML tag ` `.
290 | `resource` (OPTIONAL): Only required if the step is a resource_use step. This looks at the 'scenario' object passed to the `animate_activity_log()` function and pulls the attribute with the given name, which should give the number of available resources for that step.
291 |
292 | ```{python}
293 | event_position_df = pd.DataFrame([
294 | # Triage
295 | {'event': 'triage_wait_begins',
296 | 'x': 160, 'y': 400, 'label': "Waiting for Triage" },
297 | {'event': 'triage_begins',
298 | 'x': 160, 'y': 315, 'resource':'n_triage', 'label': "Being Triaged" },
299 |
300 | # Trauma pathway
301 | {'event': 'TRAUMA_stabilisation_wait_begins',
302 | 'x': 300, 'y': 560, 'label': "Waiting for Stabilisation" },
303 | {'event': 'TRAUMA_stabilisation_begins',
304 | 'x': 300, 'y': 500, 'resource':'n_trauma', 'label': "Being Stabilised" },
305 |
306 | {'event': 'TRAUMA_treatment_wait_begins',
307 | 'x': 630, 'y': 560, 'label': "Waiting for Treatment" },
308 | {'event': 'TRAUMA_treatment_begins',
309 | 'x': 630, 'y': 500, 'resource':'n_cubicles', 'label': "Being Treated" },
310 |
311 | {'event': 'exit',
312 | 'x': 670, 'y': 330, 'label': "Exit"}
313 | ])
314 | ```
315 |
316 | ## Creating the animation
317 | There are two main ways to create the animation:
318 | - using the one-step function `animate_activity_log()` (see pages/1_Simple_ED_interactive, pages/2_Simple_ED_Forced_Overcrowding or pages/3_Complex_ED_Interactive for examples of this)
319 | - using the functions `reshape_for_animations()`, `generate_animation_df()` and `generate_animation()` separately, passing the output of each to the next step (see pages/4_HEP_Orthopaedic_Surgery, pages/5_Community_Booking_Model, or pages/6_Community_Booking_Model_Multistep for examples of this and to get an idea of the extra customisation you can introduce with this approach)
320 |
321 | # Models used as examples
322 |
323 | ## Emergency department (Treatment Centre) model
324 | Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) Open Science for Computer Simulation
325 |
326 | https://github.com/TomMonks/treatment-centre-sim
327 |
328 | The layout code for the emergency department model: https://github.com/hsma-programme/Teaching_DES_Concepts_Streamlit
329 |
330 | ## The hospital efficiency project model
331 | Harper, A., & Monks, T. Hospital Efficiency Project Orthopaedic Planning Model Discrete-Event Simulation [Computer software]. https://doi.org/10.5281/zenodo.7951080
332 |
333 | https://github.com/AliHarp/HEP/tree/main
334 |
335 | ## Simulation model with scheduling example
336 | Monks, T.
337 |
338 | https://github.com/health-data-science-OR/stochastic_systems
339 |
340 | https://github.com/health-data-science-OR/stochastic_systems/tree/master/labs/simulation/lab5
341 |
--------------------------------------------------------------------------------
/examples/ex_4_community/model_classes.py:
--------------------------------------------------------------------------------
1 | '''
2 |
3 | Classes and functions for the scheduling example lab.
4 | This is used to build a model of the queuing and scheduling
5 | at a mental health assessment network across in Devon
6 |
7 | '''
8 |
9 | import pandas as pd
10 | import numpy as np
11 | import itertools
12 | import simpy
13 |
14 | from examples.distribution_classes import Bernoulli, Discrete, Poisson
15 |
16 | def generate_seed_vector(one_seed_to_rule_them_all=42, size=20):
17 | '''
18 | Return a controllable numpy array
19 | of integer seeds to use in simulation model.
20 |
21 | Values are between 1000 and 10^10
22 |
23 | Params:
24 | ------
25 | one_seed_to_rule_them_all: int, optional (default=42)
26 | seed to produce the seed vector
27 |
28 | size: int, optional (default=20)
29 | length of seed vector
30 | '''
31 | rng = np.random.default_rng(seed=one_seed_to_rule_them_all)
32 | return rng.integers(low=1000, high=10**10, size=size)
33 |
34 | ANNUAL_DEMAND = 16328
35 | LOW_PRIORITY_MIN_WAIT = 3
36 | HIGH_PRIORITY_MIN_WAIT = 1
37 |
38 | PROP_HIGH_PRORITY= 0.15
39 | PROP_CARVE_OUT = 0.15
40 |
41 | #target in working days
42 | TARGET_HIGH = 5
43 | TARGET_LOW = 20
44 |
45 | class Clinic():
46 | '''
47 | A clinic has a probability of refering patients
48 | to another service after triage.
49 | '''
50 | def __init__(self, prob_referral_out, random_seed=None):
51 |
52 | #prob patient is referred to another service
53 | self.prob_referral_out = prob_referral_out
54 | self.ref_out_dist = Bernoulli(prob_referral_out, random_seed)
55 |
56 | class Scenario():
57 | '''
58 | Arguments represent a configuration of the simulation model.
59 | '''
60 | def __init__(self, run_length, warm_up=0.0, pooling=False, prop_carve_out=0.15,
61 | demand_file=None, slots_file=None, pooling_file=None,
62 | seeds=None):
63 |
64 | if seeds is None:
65 | self.seeds = [None for i in range(100)]
66 | else:
67 | self.seeds = seeds
68 |
69 | #use default files?
70 | if pooling_file is None:
71 | pooling_file = pd.read_csv('examples/ex_4_community/data/partial_pooling.csv')
72 |
73 | if demand_file is None:
74 | demand_file = pd.read_csv('examples/ex_4_community/data/referrals.csv')
75 |
76 | if slots_file is None:
77 | slots_file = pd.read_csv('examples/ex_4_community/data/shifts.csv')
78 |
79 | #useful if you want to record anything during a model run.
80 | self.debug = []
81 |
82 | #run length and warm up period
83 | self.run_length = run_length
84 | self.warm_up_period = warm_up
85 |
86 | #should we pool clinics?
87 | self.pooling = pooling
88 |
89 | #proportion of carve out used
90 | self.prop_carve_out = prop_carve_out
91 |
92 | #input data from files
93 | self.clinic_demand = demand_file
94 | self.weekly_slots = slots_file
95 | self.pooling_np = pooling_file.to_numpy().T[1:].T
96 |
97 | #These represent the 'diaries' of bookings
98 |
99 | # 1. carve out
100 | self.carve_out_slots = self.create_carve_out(run_length,
101 | self.weekly_slots)
102 |
103 | # 2. available slots and one for the bookings.
104 | self.available_slots = self.create_slots(self.run_length,
105 | self.weekly_slots)
106 |
107 | # 3. the bookings which can be used to calculate slot utilisation
108 | self.bookings = self.create_bookings(self.run_length,
109 | len(self.weekly_slots.columns))
110 |
111 | #sampling distributions
112 | self.arrival_dist = Poisson(ANNUAL_DEMAND / 52 / 5,
113 | random_seed=self.seeds[0])
114 | self.priority_dist = Bernoulli(PROP_HIGH_PRORITY,
115 | random_seed=self.seeds[1])
116 |
117 | #create a distribution for sampling a patients local clinic.
118 | elements = [i for i in range(len(self.clinic_demand))]
119 | probs = self.clinic_demand['prop'].to_numpy()
120 | self.clinic_dist = Discrete(elements, probs, random_seed=self.seeds[2])
121 |
122 | #create a list of clinic objects
123 | self.clinics = []
124 | for i in range(len(self.clinic_demand)):
125 | clinic = Clinic(self.clinic_demand['referred_out'].iloc[i],
126 | random_seed=self.seeds[i+3])
127 | self.clinics.append(clinic)
128 |
129 | def create_carve_out(self, run_length, capacity_template):
130 |
131 | #proportion of total capacity carved out for high priority patients
132 | priority_template = (capacity_template * self.prop_carve_out).round().astype(np.uint8)
133 |
134 | priority_slots = priority_template.copy()
135 |
136 | #longer than run length as patients will need to book ahead
137 | for day in range(int(run_length*1.5)):
138 | priority_slots = pd.concat([priority_slots, priority_template.copy()],
139 | ignore_index=True)
140 |
141 | priority_slots.index.rename('day', inplace=True)
142 | return priority_slots
143 |
144 | def create_slots(self, run_length, capacity_template):
145 |
146 | priority_template = (capacity_template * self.prop_carve_out).round().astype(np.uint8)
147 | open_template = capacity_template - priority_template
148 | available_slots = open_template.copy()
149 |
150 | #longer than run length as patients will need to book ahead
151 | for day in range(int(run_length*1.5)):
152 | available_slots = pd.concat([available_slots, open_template.copy()],
153 | ignore_index=True)
154 |
155 | available_slots.index.rename('day', inplace=True)
156 | return available_slots
157 |
158 | def create_bookings(self, run_length, clinics):
159 | bookings = np.zeros(shape=(5, clinics), dtype=np.uint8)
160 |
161 | columns = [f'clinic_{i}' for i in range(1, clinics+1)]
162 | bookings_template = pd.DataFrame(bookings, columns=columns)
163 |
164 | bookings = bookings_template.copy()
165 |
166 | #longer than run length as patients will need to book ahead
167 | for day in range(int(run_length*1.5)):
168 | bookings = pd.concat([bookings, bookings_template.copy()],
169 | ignore_index=True)
170 |
171 | bookings.index.rename('day', inplace=True)
172 | return bookings
173 |
174 | class LowPriorityPooledBooker():
175 | '''
176 | Low prioity booking process for POOLED clinics.
177 |
178 | Low priority patients only have access to public slots and have a minimum
179 | waiting time (e.g. 3 days before a slot can be used.)
180 | '''
181 | def __init__(self, args):
182 | self.args = args
183 | self.min_wait = LOW_PRIORITY_MIN_WAIT
184 | self.priority = 1
185 |
186 |
187 | def find_slot(self, t, clinic_id):
188 | '''
189 | Finds a slot in a diary of available slot
190 |
191 | NUMPY IMPLEMENTATION.
192 |
193 | Params:
194 | ------
195 | t: int,
196 | time t in days
197 |
198 | clinic_id: int
199 | home clinic id is the index of the clinic column in diary
200 |
201 | Returns:
202 | -------
203 | (int, int)
204 | (best_t, best_clinic_id)
205 |
206 | '''
207 | #to reduce runtime - drop down to numpy...
208 | available_slots_np = self.args.available_slots.to_numpy()
209 |
210 | #get the clinics that are pooled with this one.
211 | clinic_options = np.where(self.args.pooling_np[clinic_id] == 1)[0]
212 |
213 | #get the clinic slots t+min_wait forward for the pooled clinics
214 | clinic_slots = available_slots_np[t+self.min_wait:, clinic_options]
215 |
216 | #get the earliest day number (its the name of the series)
217 | best_t = np.where((clinic_slots.sum(axis=1) > 0))[0][0]
218 |
219 | #get the index of the best clinic option.
220 | best_clinic_idx = clinic_options[clinic_slots[best_t, :] > 0][0]
221 |
222 | #return (best_t, booked_clinic_id)
223 | return best_t + self.min_wait + t, best_clinic_idx
224 |
225 |
226 | def book_slot(self, booking_t, clinic_id):
227 | '''
228 | Book a slot on day t for clinic c
229 |
230 | A slot is removed from args.available_slots
231 | A appointment is recorded in args.bookings.iat
232 |
233 | Params:
234 | ------
235 | booking_t: int
236 | Day of booking
237 |
238 | clinic_id: int
239 | the clinic identifier
240 | '''
241 | #one less public available slot
242 | self.args.available_slots.iat[booking_t, clinic_id] -= 1
243 |
244 | #one more patient waiting
245 | self.args.bookings.iat[booking_t, clinic_id] += 1
246 |
247 | class HighPriorityBooker():
248 | '''
249 | High prioity booking process
250 |
251 | High priority patients are a minority, but require urgent access to services.
252 | They booking process has access to public slots and carve out slots. High
253 | priority patient still have a delay before booking, but this is typically
254 | small e.g. next day slots.
255 | '''
256 | def __init__(self, args):
257 | '''
258 | Constructor
259 |
260 | Params:
261 | ------
262 | args: Scenario
263 | simulation input parameters including the booking sheets
264 | '''
265 | self.args = args
266 | self.min_wait = 1
267 | self.priority = 2
268 |
269 | def find_slot(self, t, clinic_id):
270 | '''
271 | Finds a slot in a diary of available slots
272 |
273 | High priority patients have access to both
274 | public slots and carve out reserved slots.
275 |
276 | Params:
277 | ------
278 | t: int,
279 | time t in days
280 |
281 | clinic_id: int
282 | clinic id is the index of the clinic column in diary
283 |
284 | Returns:
285 | -------
286 | (int, int)
287 | (best_t, best_clinic_id)
288 | '''
289 |
290 | #to reduce runtime - maybe...
291 | available_slots_np = self.args.available_slots.to_numpy()
292 | carve_out_slots_np = self.args.carve_out_slots.to_numpy()
293 |
294 | #get the clinic slots from t+min_wait days forward
295 | #priority slots
296 | priority_slots = carve_out_slots_np[t+self.min_wait:, clinic_id]
297 |
298 | #public slots
299 | public_slots = available_slots_np[t+self.min_wait:, clinic_id]
300 |
301 | #total slots
302 | clinic_slots = priority_slots + public_slots
303 |
304 | #(best_t, best_clinic_id)
305 | return np.argmax(clinic_slots > 0) + self.min_wait + t, clinic_id
306 |
307 | def book_slot(self, booking_t, clinic_id):
308 | '''
309 | Book a slot on day t for clinic c
310 |
311 | A slot is removed from args.carve_out_slots or
312 | args.available_slots if required.
313 |
314 | A appointment is recorded in args.bookings.iat
315 |
316 | Params:
317 | ------
318 | booking_t: int
319 | Day of booking
320 |
321 | clinic_id: int
322 | the clinic identifier
323 |
324 | '''
325 | #take carve out slot first
326 | if self.args.carve_out_slots.iat[booking_t, clinic_id] > 0:
327 | self.args.carve_out_slots.iat[booking_t, clinic_id] -= 1
328 | else:
329 | #one less public available slot
330 | self.args.available_slots.iat[booking_t, clinic_id] -= 1
331 |
332 | #one more booking...
333 | self.args.bookings.iat[booking_t, clinic_id] += 1
334 |
335 | class LowPriorityBooker():
336 | '''
337 | Low prioity booking process
338 |
339 | Low priority patients only have access to public slots and have a minimum
340 | waiting time (e.g. 3 days before a slot can be used.)
341 | '''
342 | def __init__(self, args):
343 | self.args = args
344 | self.min_wait = LOW_PRIORITY_MIN_WAIT
345 | self.priority = 1
346 |
347 | def find_slot(self, t, clinic_id):
348 | '''
349 | Finds a slot in a diary of available slot
350 |
351 | Params:
352 | ------
353 | t: int,
354 | time t in days
355 |
356 | clinic_id: int
357 | clinic id is the index of the clinic column in diary
358 |
359 | Returns:
360 | -------
361 | (int, int)
362 | (best_t, best_clinic_id)
363 | '''
364 | #to reduce runtime drop from pandas to numpy
365 | available_slots_np = self.args.available_slots.to_numpy()
366 |
367 | #get the clinic slots t+min_wait forward for the pooled clinics
368 | clinic_slots = available_slots_np[t+self.min_wait:, clinic_id]
369 |
370 | # return (best_t, best_clinic_id)
371 | return np.argmax(clinic_slots > 0) + self.min_wait + t, clinic_id
372 |
373 |
374 | def book_slot(self, booking_t, clinic_id):
375 | '''
376 | Book a slot on day t for clinic c
377 |
378 | A slot is removed from args.available_slots
379 | A appointment is recorded in args.bookings.iat
380 |
381 | Params:
382 | ------
383 | booking_t: int
384 | Day of booking
385 |
386 | clinic_id: int
387 | the clinic identifier
388 | '''
389 | #one less public available slot
390 | self.args.available_slots.iat[booking_t, clinic_id] -= 1
391 |
392 | #one more patient waiting
393 | self.args.bookings.iat[booking_t, clinic_id] += 1
394 |
395 | class HighPriorityPooledBooker():
396 | '''
397 | High prioity booking process for POOLED clinics.
398 |
399 | High priority patients have access to public and reserved
400 | slots and have a minimum waiting time (e.g. 1 days before a
401 | slot can be used.)
402 | '''
403 | def __init__(self, args):
404 | self.args = args
405 | self.min_wait = 1
406 | self.priority = 2
407 |
408 |
409 | def find_slot(self, t, clinic_id):
410 | '''
411 | Finds a slot in a diary of available slot
412 |
413 | NUMPY IMPLEMENTATION.
414 |
415 | Params:
416 | ------
417 | t: int,
418 | time t in days
419 |
420 | clinic_id: int
421 | home clinic id is the index of the clinic column in diary
422 |
423 | Returns:
424 | -------
425 | (int, int)
426 | (best_t, best_clinic_id)
427 |
428 | '''
429 | #to reduce runtime - drop down to numpy...
430 | available_slots_np = self.args.available_slots.to_numpy()
431 | carve_out_slots_np = self.args.carve_out_slots.to_numpy()
432 |
433 | #get the clinics that are pooled with this one.
434 | clinic_options = np.where(self.args.pooling_np[clinic_id] == 1)[0]
435 |
436 | #get the clinic slots t+min_wait forward for the pooled clinics
437 | public_slots = available_slots_np[t+self.min_wait:, clinic_options]
438 | priority_slots = carve_out_slots_np[t+self.min_wait:, clinic_options]
439 |
440 | #total slots
441 | clinic_slots = priority_slots + public_slots
442 |
443 | #get the earliest day number (its the name of the series)
444 | best_t = np.where((clinic_slots.sum(axis=1) > 0))[0][0]
445 |
446 | #get the index of the best clinic option.
447 | best_clinic_idx = clinic_options[clinic_slots[best_t, :] > 0][0]
448 |
449 | #return (best_t, best_clinic_id)
450 | return best_t + self.min_wait + t, best_clinic_idx
451 |
452 |
453 | def book_slot(self, booking_t, clinic_id):
454 | '''
455 | Book a slot on day t for clinic c
456 |
457 | A slot is removed from args.available_slots
458 | A appointment is recorded in args.bookings.iat
459 |
460 | Params:
461 | ------
462 | booking_t: int
463 | Day of booking
464 |
465 | clinic_id: int
466 | the clinic identifier
467 | '''
468 | #take carve out slot first
469 | if self.args.carve_out_slots.iat[booking_t, clinic_id] > 0:
470 | self.args.carve_out_slots.iat[booking_t, clinic_id] -= 1
471 | else:
472 | #one less public available slot
473 | self.args.available_slots.iat[booking_t, clinic_id] -= 1
474 |
475 | #one more booking...
476 | self.args.bookings.iat[booking_t, clinic_id] += 1
477 |
478 | class PatientReferral(object):
479 | '''
480 | Patient referral process
481 |
482 | Find an appropraite asessment slot for the patient.
483 | Schedule an assessment for that day.
484 |
485 | '''
486 | def __init__(self, env, args, referral_t, home_clinic, booker, event_log, identifier):
487 | self.env = env
488 | self.args = args
489 | self.referral_t = referral_t
490 | self.home_clinic = home_clinic
491 | self.booked_clinic = home_clinic
492 | self.booker = booker
493 | self.event_log = event_log
494 | self.identifier = identifier
495 |
496 |
497 | #performance metrics
498 | self.waiting_time = None
499 |
500 | @property
501 | def priority(self):
502 | '''
503 | Return the priority of the patient booking
504 | '''
505 | return self.booker.priority
506 |
507 | def execute(self):
508 | '''
509 | Patient is referred to clinic
510 |
511 | 1. find earliest slot within rules
512 | 2. book slot at clinic
513 | 3. schedule process to complete at that time
514 | '''
515 | self.event_log.append(
516 | {'patient': self.identifier,
517 | 'pathway': self.priority,
518 | 'event_type': 'arrival_departure',
519 | 'event': 'arrival',
520 | 'home_clinic': int(self.home_clinic),
521 | 'time': self.env.now}
522 | )
523 |
524 |
525 | #get slot for clinic
526 | best_t, self.booked_clinic = \
527 | self.booker.find_slot(self.referral_t, self.home_clinic)
528 |
529 | #book slot at clinic = time of referral + waiting_time
530 | self.booker.book_slot(best_t, self.booked_clinic)
531 |
532 | self.event_log.append(
533 | {'patient': self.identifier,
534 | 'pathway': self.priority,
535 | 'event_type': 'queue',
536 | 'event': 'appointment_booked_waiting',
537 | 'booked_clinic': int(self.booked_clinic),
538 | 'home_clinic': int(self.home_clinic),
539 | 'time': self.env.now
540 | }
541 | )
542 |
543 | #wait for appointment
544 | yield self.env.timeout(best_t - self.referral_t)
545 |
546 | # measure waiting time on day of appointment
547 | #(could also record this before appointment, but leaving until
548 | #afterwards allows modifications where patients can be moved)
549 | self.waiting_time = best_t - self.referral_t
550 |
551 | # Use appointment
552 | self.event_log.append(
553 | {'patient': self.identifier,
554 | 'pathway': self.priority,
555 | 'event_type': 'queue',
556 | 'event': 'have_appointment',
557 | 'booked_clinic': int(self.booked_clinic),
558 | 'home_clinic': int(self.home_clinic),
559 | 'time': self.env.now,
560 | 'wait': self.waiting_time
561 | }
562 | )
563 |
564 | self.event_log.append(
565 | {'patient': self.identifier,
566 | 'pathway': self.priority,
567 | 'event_type': 'arrival_departure',
568 | 'event': 'depart',
569 | 'home_clinic': int(self.home_clinic),
570 | 'time': self.env.now+1}
571 | )
572 |
573 |
574 |
575 | class AssessmentReferralModel(object):
576 | '''
577 | Implements the Mental Wellbeing and Access 'Assessment Referral'
578 | model in Pitt, Monks and Allen (2015). https://bit.ly/3j8OH6y
579 |
580 | Patients arrive at random and in proportion to the regional team.
581 |
582 | Patients may be seen by any team identified by a pooling matrix.
583 | This includes limiting a patient to only be seen by their local team.
584 |
585 | The model reports average waiting time and can be used to compare
586 | full, partial and no pooling of appointments.
587 |
588 | '''
589 | def __init__(self, args):
590 | '''
591 | Constructor
592 |
593 | Params:
594 | ------
595 |
596 | args: Scenario
597 | Arguments for the simulation model
598 |
599 | '''
600 | self.env = simpy.Environment()
601 | self.args = args
602 |
603 | #list of patients referral processes
604 | self.referrals = []
605 |
606 | self.event_log = []
607 |
608 | #simpy processes
609 | self.env.process(self.generate_arrivals())
610 |
611 | def run(self):
612 | '''
613 | Conduct a single run of the simulation model.
614 | '''
615 | self.env.run(self.args.run_length)
616 | self.process_run_results()
617 |
618 | def generate_arrivals(self):
619 | '''
620 | Time slicing simulation. The model steps forward by a single
621 | day and simulates the number of arrivals from a Poisson
622 | distribution. The following process is then applied.
623 |
624 | 1. Sample the region of the referral from a Poisson distribution
625 | 2. Triage - is an appointment made for the patient or are they referred
626 | to another service?
627 | 3. A referral process is initiated for the patient.
628 |
629 | '''
630 | #loop a day at a time.
631 | for t in itertools.count():
632 |
633 | #total number of referrals today
634 | n_referrals = self.args.arrival_dist.sample()
635 |
636 | #loop through all referrals recieved that day
637 | for i in range(n_referrals):
638 | #sample clinic based on empirical proportions
639 | clinic_id = self.args.clinic_dist.sample()
640 | clinic = self.args.clinics[clinic_id]
641 |
642 | #triage patient and refer out of system if appropraite
643 | referred_out = clinic.ref_out_dist.sample()
644 |
645 | #if patient is accepted to clinic
646 | if referred_out == 0:
647 |
648 | #is patient high priority?
649 | high_priority = self.args.priority_dist.sample()
650 |
651 | if high_priority == 1:
652 | #different policy if pooling or not
653 | if self.args.pooling:
654 | booker = HighPriorityPooledBooker(self.args)
655 | else:
656 | booker = HighPriorityBooker(self.args)
657 | else:
658 | #different policy if pooling or not
659 | if self.args.pooling:
660 | booker = LowPriorityPooledBooker(self.args)
661 | else:
662 | booker = LowPriorityBooker(self.args)
663 |
664 | #create instance of PatientReferral
665 | patient = PatientReferral(self.env, self.args, t,
666 | clinic_id, booker, self.event_log, f"{t}_{i}")
667 |
668 | #start a referral assessment process for patient.
669 | self.env.process(patient.execute())
670 |
671 | #only collect results after warm-up complete
672 | if self.env.now > self.args.warm_up_period:
673 | #store patient for calculating waiting time stats at end
674 | self.referrals.append(patient)
675 |
676 | # Add event logging for patients triaged and referred out
677 | if referred_out == 1:
678 | self.event_log.append(
679 | {'patient': f"{t}_{i}",
680 | 'pathway': "Unsuitable for service",
681 | 'event_type': 'arrival_departure',
682 | 'event': 'arrival',
683 | 'home_clinic': int(clinic_id),
684 | 'time': self.env.now
685 | }
686 | )
687 |
688 | self.event_log.append(
689 | {'patient': f"{t}_{i}",
690 | 'pathway': "Unsuitable for service",
691 | 'event_type': 'queue',
692 | 'event': f'referred_out_{clinic_id}',
693 | 'home_clinic': int(clinic_id),
694 | 'time': self.env.now
695 | }
696 | )
697 |
698 | self.event_log.append(
699 | {'patient': f"{t}_{i}",
700 | 'pathway': "Unsuitable for service",
701 | 'event_type': 'arrival_departure',
702 | 'event': 'depart',
703 | 'home_clinic': int(clinic_id),
704 | 'time': self.env.now + 1
705 | }
706 | )
707 |
708 | #timestep by one day
709 | yield self.env.timeout(1)
710 |
711 | def process_run_results(self):
712 | '''
713 | Produce summary results split by priority...
714 | '''
715 |
716 | results_all = [p.waiting_time for p in self.referrals
717 | if not p.waiting_time is None]
718 |
719 | results_low = [p.waiting_time for p in self.referrals
720 | if not (p.waiting_time is None) and p.priority == 1]
721 |
722 | results_high = [p.waiting_time for p in self.referrals
723 | if (not p.waiting_time is None) and p.priority == 2]
724 |
725 | self.results_all = results_all
726 | self.results_low = results_low
727 | self.results_high = results_high
728 |
--------------------------------------------------------------------------------
/pages/4_HEP_Orthopaedic_Surgery.py:
--------------------------------------------------------------------------------
1 | import gc
2 | import time
3 | import datetime as dt
4 | import streamlit as st
5 | import pandas as pd
6 | import plotly.express as px
7 | import plotly.graph_objects as go
8 | from examples.ex_3_theatres_beds.simulation_execution_functions import (
9 | multiple_replications,
10 | )
11 | from examples.ex_3_theatres_beds.model_classes import Scenario, Schedule
12 | from vidigi.prep import reshape_for_animations, generate_animation_df
13 | from vidigi.animation import generate_animation
14 | from plotly.subplots import make_subplots
15 |
16 | st.set_page_config(
17 | layout="wide", initial_sidebar_state="expanded", page_title="Orthopaedic Ward - HEP"
18 | )
19 |
20 | gc.collect()
21 |
22 | st.title("Orthopaedic Ward - Hospital Efficiency Project")
23 |
24 | st.markdown(
25 | """
26 | This is the orthopaedic surgery model developed as part of the hospital efficiency project.
27 | """
28 | )
29 |
30 | st.info(
31 | """
32 | Credit for original model:
33 |
34 | **Harper, Alison**, **Monks, Thomas** and **Pitt, Martin**.
35 |
36 | *Hospital Efficiency Project: Orthopaedic Planning Model Discrete-Event Simulation*.
37 |
38 | [GitHub Repository: https://github.com/AliHarp/HEP](https://github.com/AliHarp/HEP) — MIT License.
39 |
40 | [](https://doi.org/10.5281/zenodo.7951080)
41 |
42 | [Hosted App: https://hospital-efficiency-project.streamlit.app/](https://hospital-efficiency-project.streamlit.app/)
43 | """
44 | )
45 |
46 | st.markdown(
47 | """
48 | It has been used as a test case here to allow the development and testing of several key features of the event log animations:
49 |
50 | - adding of logging to a model from scratch
51 |
52 | - ensuring the requirement to use simpy stores instead of simpy resources doesn't prevent the uses of certain common modelling patterns (in this case, conditional logic where patients will leave the system if a bed is not available within a specified period of time)
53 |
54 | - displaying different icons for different classes of patients
55 |
56 | - displaying custom resource icons
57 |
58 | - displaying additional static information as part of the icon (in this case, whether the client's discharge is delayed)
59 |
60 | - displaying information that updates with each animation step as part of the icon (in this case, the LoS of the patient at each time point)
61 | """
62 | )
63 |
64 | st.divider()
65 |
66 | TRACE = True
67 | debug_mode = True
68 |
69 | st.info("For simplicity, here we fix the theatre schedule as follows.")
70 |
71 | schedule = Schedule()
72 |
73 | col_a, col_b = st.columns(2)
74 |
75 | with col_a:
76 | st.markdown(
77 | """
78 | 4 theatres
79 |
80 | 5 day/week
81 |
82 | Each theatre has three sessions per day:
83 |
84 | Morning: 1 revision OR 2 primary
85 |
86 | Afternoon: 1 revision OR 2 primary
87 |
88 | Evening: 1 primary
89 |
90 | 40 ring-fenced beds for recovery from these operations
91 | """
92 | )
93 |
94 | with col_b:
95 | st.dataframe(
96 | pd.DataFrame.from_dict(schedule.sessions_per_weekday, orient="index")
97 | .rename(columns={0: "Sessions"})
98 | .merge(
99 | pd.DataFrame.from_dict(
100 | schedule.theatres_per_weekday, orient="index"
101 | ).rename(columns={0: "Theatre Capacity"}),
102 | left_index=True,
103 | right_index=True,
104 | )
105 | .merge(
106 | pd.DataFrame.from_dict(schedule.allocation, orient="index"),
107 | left_index=True,
108 | right_index=True,
109 | )
110 | )
111 |
112 |
113 | st.divider()
114 |
115 | st.markdown("# Model Parameters")
116 |
117 | st.info("The following parameters can be edited")
118 |
119 |
120 | col1, col2 = st.columns(2)
121 |
122 | with col1:
123 | st.markdown("### Ring-fenced beds:")
124 | n_beds = st.slider("Beds", 10, 80, 40, 1)
125 |
126 | st.markdown("### Mean lengths-of-stay for each type of surgery:")
127 | primary_hip_los = st.slider("Primary Hip LoS", 1.0, 10.0, 4.4, 0.1)
128 |
129 | primary_knee_los = st.slider("Primary Knee LoS", 1.0, 10.0, 4.7, 0.1)
130 |
131 | revision_hip_los = st.slider("Revision Hip LoS", 2.0, 10.0, 6.9, 0.1)
132 |
133 | revision_knee_los = st.slider("Revision Knee LoS", 2.0, 10.0, 7.2, 0.1)
134 |
135 | unicompart_knee_los = st.slider("Unicompart knee LoS", 1.0, 10.0, 2.9, 0.1)
136 |
137 | with col2:
138 | st.markdown("### Mean length of delayed discharge:")
139 | los_delay = st.slider("Mean length of delay", 2.0, 10.0, 16.5, 0.1)
140 | los_delay_sd = st.slider(
141 | "Variation of delay (standard deviation)", 1.0, 25.0, 15.2, 0.1
142 | )
143 |
144 | st.markdown("### Proportion of patients with a discharge delay:")
145 | prop_delay = st.slider("Proportion delayed", 0.00, 1.00, 0.076, 0.01)
146 |
147 | st.markdown("### :green[Model execution]")
148 | replications = st.slider(":green[Number of runs]", 1, 50, 30)
149 | runtime = st.slider(":green[Runtime (days)]", 30, 100, 60)
150 | warmup = st.slider(":green[Warmup (days)]", 1, 14, 7)
151 |
152 | st.header("Animation parameters")
153 |
154 | anim_param_col_1, anim_param_col_2 = st.columns(2)
155 |
156 | show_los = anim_param_col_1.toggle("Show Individual Length of Stay")
157 | show_delayed_discharges = anim_param_col_1.toggle("Show Delayed Discharges")
158 | show_operation_type = anim_param_col_2.selectbox(
159 | "Choose Surgery Detail to Display",
160 | [
161 | "Show knee vs hip",
162 | "Show revision vs primary",
163 | "Show both",
164 | "Show standard patient icons",
165 | ],
166 | index=0,
167 | )
168 |
169 | st.divider()
170 |
171 | st.info("When you are ready, click 'Run simulation' below!")
172 |
173 | button_run_pressed = st.button("Run simulation", type="primary")
174 |
175 | args = Scenario(
176 | schedule=schedule,
177 | primary_hip_mean_los=primary_hip_los,
178 | primary_knee_mean_los=primary_knee_los,
179 | revision_hip_mean_los=revision_hip_los,
180 | revision_knee_mean_los=revision_knee_los,
181 | unicompart_knee_mean_los=unicompart_knee_los,
182 | prob_ward_delay=prop_delay,
183 | n_beds=n_beds,
184 | delay_post_los_mean=los_delay,
185 | delay_post_los_sd=los_delay_sd,
186 | )
187 |
188 | if button_run_pressed:
189 |
190 | @st.fragment
191 | def generate_animation_fig():
192 | results = multiple_replications(
193 | return_detailed_logs=True,
194 | scenario=args,
195 | n_reps=replications,
196 | results_collection=runtime,
197 | )
198 | with st.expander("Click to see a summary of the results"):
199 | st.subheader("Summary of Results")
200 | st.dataframe(results[0])
201 |
202 | # Join the event log with a list of patients to add a column that will determine
203 | # the icon set used for a patient (in this case, we want to distinguish between the
204 | # knee/hip patients)
205 | event_log = results[4]
206 | event_log = event_log[event_log["rep"] == 1]
207 | event_log["patient"] = event_log["patient"].astype("str") + event_log["pathway"]
208 |
209 | primary_patients = results[2]
210 | primary_patients = primary_patients[primary_patients["rep"] == 1]
211 | primary_patients["patient class"] = primary_patients[
212 | "patient class"
213 | ].str.title()
214 | primary_patients["ID"] = (
215 | primary_patients["ID"].astype("str") + primary_patients["patient class"]
216 | )
217 |
218 | revision_patients = results[3]
219 | revision_patients = revision_patients[revision_patients["rep"] == 1]
220 | revision_patients["patient class"] = revision_patients[
221 | "patient class"
222 | ].str.title()
223 | revision_patients["ID"] = (
224 | revision_patients["ID"].astype("str") + revision_patients["patient class"]
225 | )
226 |
227 | full_log_with_patient_details = (
228 | event_log.merge(
229 | pd.concat([primary_patients, revision_patients]),
230 | how="left",
231 | left_on=["patient", "pathway"],
232 | right_on=["ID", "patient class"],
233 | )
234 | .reset_index(drop=True)
235 | .drop(columns="ID")
236 | )
237 |
238 | pid_table = (
239 | full_log_with_patient_details[["patient"]]
240 | .drop_duplicates()
241 | .reset_index(drop=True)
242 | .reset_index(drop=False)
243 | .rename(columns={"index": "pid"})
244 | )
245 |
246 | full_log_with_patient_details = (
247 | full_log_with_patient_details.merge(pid_table, how="left", on="patient")
248 | .drop(columns="patient")
249 | .rename(columns={"pid": "patient"})
250 | )
251 |
252 | event_position_df = pd.DataFrame(
253 | [
254 | # {'event': 'arrival', 'x': 10, 'y': 250, 'label': "Arrival" },
255 | # Triage - minor and trauma
256 | {
257 | "event": "enter_queue_for_bed",
258 | "x": 200,
259 | "y": 650,
260 | "label": "Waiting for Availability of Bed to be Confirmed Before Surgery",
261 | },
262 | {
263 | "event": "no_bed_available",
264 | "x": 600,
265 | "y": 650,
266 | "label": "No Bed Available: Surgery Cancelled",
267 | },
268 | {
269 | "event": "post_surgery_stay_begins",
270 | "x": 650,
271 | "y": 220,
272 | "resource": "n_beds",
273 | "label": "In Bed: Recovering from Surgery",
274 | },
275 | {
276 | "event": "discharged_after_stay",
277 | "x": 670,
278 | "y": 50,
279 | "label": "Discharged from Hospital After Recovery",
280 | },
281 | # {'event': 'exit',
282 | # 'x': 670, 'y': 100, 'label': "Exit"}
283 | ]
284 | )
285 |
286 | full_patient_df = reshape_for_animations(
287 | full_log_with_patient_details,
288 | entity_col_name="patient",
289 | every_x_time_units=1,
290 | limit_duration=runtime,
291 | step_snapshot_max=50,
292 | debug_mode=debug_mode,
293 | )
294 |
295 | if debug_mode:
296 | print(
297 | f"Reshaped animation dataframe finished construction at {time.strftime('%H:%M:%S', time.localtime())}"
298 | )
299 |
300 | full_patient_df_plus_pos = generate_animation_df(
301 | full_entity_df=full_patient_df,
302 | event_position_df=event_position_df,
303 | entity_col_name="patient",
304 | wrap_queues_at=20,
305 | wrap_resources_at=40,
306 | step_snapshot_max=50,
307 | gap_between_entities=15,
308 | gap_between_resources=15,
309 | gap_between_queue_rows=175,
310 | debug_mode=debug_mode,
311 | gap_between_resource_rows=200,
312 | )
313 |
314 | def set_icon_standard(row):
315 | return f"{row['icon']} "
316 |
317 | def set_icon_surgery_target(row):
318 | if "knee" in row["surgery type"]:
319 | return "🦵 "
320 | elif "hip" in row["surgery type"]:
321 | return "🕺 "
322 | else:
323 | return f"CHECK {row['icon']}"
324 |
325 | def set_icon_surgery_type(row):
326 | if "p_" in row["surgery type"]:
327 | return "1️⃣ "
328 | elif "r_" in row["surgery type"]:
329 | return "♻️ "
330 | elif "uni_" in row["surgery type"]:
331 | return "✳️ "
332 | else:
333 | return f"CHECK {row['icon']}"
334 |
335 | def set_icon_full(row):
336 | if row["surgery type"] == "p_knee":
337 | return "🦵 1️⃣ "
338 | elif row["surgery type"] == "r_knee":
339 | return "🦵 ♻️ "
340 | elif row["surgery type"] == "p_hip":
341 | return "🕺 1️⃣ "
342 | elif row["surgery type"] == "r_hip":
343 | return "🕺 ♻️ "
344 | elif row["surgery type"] == "uni_knee":
345 | return "🦵 ✳️ "
346 | else:
347 | return f"CHECK {row['icon']}"
348 |
349 | if show_operation_type == "Show both":
350 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
351 | icon=full_patient_df_plus_pos.apply(set_icon_full, axis=1)
352 | )
353 | elif show_operation_type == "Show knee vs hip":
354 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
355 | icon=full_patient_df_plus_pos.apply(set_icon_surgery_target, axis=1)
356 | )
357 | elif show_operation_type == "Show revision vs primary":
358 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
359 | icon=full_patient_df_plus_pos.apply(set_icon_surgery_type, axis=1)
360 | )
361 | else:
362 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
363 | icon=full_patient_df_plus_pos.apply(set_icon_standard, axis=1)
364 | )
365 |
366 | # TODO: Check why this doesn't seem to be working quite right for the 'discharged after stay'
367 | # step. e.g. 194Primary is discharged on 28th July showing a LOS of 1 but prior to this shows a LOS of 9.
368 | def add_los_to_icon(row):
369 | if row["event"] == "post_surgery_stay_begins":
370 | return f"{row['icon']} {row['snapshot_time'] - row['time']:.0f}"
371 | elif row["event"] == "discharged_after_stay":
372 | return f"{row['icon']} {row['los']:.0f}"
373 | else:
374 | return row["icon"]
375 |
376 | if show_los:
377 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
378 | icon=full_patient_df_plus_pos.apply(add_los_to_icon, axis=1)
379 | )
380 |
381 | def indicate_delay_via_icon(row):
382 | if row["delayed discharge"] is True:
383 | return f"{row['icon']} *"
384 | else:
385 | return f"{row['icon']} "
386 |
387 | if show_delayed_discharges:
388 | full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
389 | icon=full_patient_df_plus_pos.apply(indicate_delay_via_icon, axis=1)
390 | )
391 |
392 | with st.expander("Click here to view detailed event dataframes"):
393 | st.subheader("Event Log")
394 | st.subheader("Data - After merging full log with patient details")
395 | st.dataframe(full_log_with_patient_details)
396 | st.subheader("Dataframe - Reshaped for animation (step 1)")
397 | st.dataframe(full_patient_df)
398 | st.subheader("Dataframe - Reshaped for animation (step 2)")
399 | st.dataframe(full_patient_df_plus_pos)
400 |
401 | cancelled_due_to_no_bed_available = len(
402 | full_log_with_patient_details[
403 | full_log_with_patient_details["event"] == "no_bed_available"
404 | ]["patient"].unique()
405 | )
406 | total_patients = len(full_log_with_patient_details["patient"].unique())
407 |
408 | cancelled_perc = cancelled_due_to_no_bed_available / total_patients
409 |
410 | st.markdown(
411 | f"Surgeries cancelled due to no bed being available in time: {cancelled_perc:.2%} ({cancelled_due_to_no_bed_available} of {total_patients})"
412 | )
413 |
414 | fig = generate_animation(
415 | full_entity_df_plus_pos=full_patient_df_plus_pos,
416 | event_position_df=event_position_df,
417 | scenario=args,
418 | entity_col_name="patient",
419 | plotly_height=950,
420 | plotly_width=1000,
421 | override_x_max=800,
422 | override_y_max=1000,
423 | text_size=14,
424 | resource_icon_size=16,
425 | entity_icon_size=14,
426 | wrap_resources_at=40,
427 | gap_between_resources=15,
428 | include_play_button=True,
429 | add_background_image=None,
430 | # we want the stage labels, but due to a bug
431 | # when we add in additional animated traces later,
432 | # they will disappear - so better to leave them out here
433 | # and then re-add them manually
434 | display_stage_labels=False,
435 | custom_resource_icon="🛏️",
436 | time_display_units="d",
437 | simulation_time_unit="day",
438 | start_date="2022-06-27",
439 | setup_mode=False,
440 | frame_duration=1500, # milliseconds
441 | frame_transition_duration=1000, # milliseconds
442 | debug_mode=False,
443 | gap_between_resource_rows=200,
444 | )
445 |
446 | # Create an additional dataframe calculating the number of times cancellations occur due
447 | # to no bed being available
448 | counts_not_avail = (
449 | full_patient_df_plus_pos[
450 | full_patient_df_plus_pos["event"] == "no_bed_available"
451 | ]
452 | .sort_values("snapshot_time")[["snapshot_time", "patient"]]
453 | .groupby("snapshot_time")
454 | .agg("count")
455 | )
456 |
457 | # Ensure we have a value for every snapshot time in the animation by using this as the
458 | # index - this avoids the risk of the number of frames represented in this dataframe not
459 | # matching the total number of animation frames in the actual output figure
460 | counts_not_avail = (
461 | counts_not_avail.reset_index()
462 | .merge(
463 | full_patient_df_plus_pos[["snapshot_time"]].drop_duplicates(),
464 | how="right",
465 | )
466 | .sort_values("snapshot_time")
467 | ).reset_index(drop=True)
468 |
469 | counts_not_avail["patient"] = counts_not_avail["patient"].fillna(0)
470 |
471 | # Calculate a running total of this value, which will be used to add the correct value
472 | # to each individual frame
473 | counts_not_avail["running_total"] = counts_not_avail.sort_values(
474 | "snapshot_time"
475 | )["patient"].cumsum()
476 |
477 | # Create an additional dataframe calculating the number of operations completed per day
478 | counts_ops_completed = (
479 | full_patient_df_plus_pos[
480 | full_patient_df_plus_pos["event"] == "post_surgery_stay_begins"
481 | ][["snapshot_time", "patient"]]
482 | .drop_duplicates("patient")
483 | .groupby("snapshot_time")
484 | .agg("count")
485 | )
486 | # Ensure we have a value for every snapshot time in the animation by using this as the
487 | # index - this avoids the risk of the number of frames represented in this dataframe not
488 | # matching the total number of animation frames in the actual output figure
489 | counts_ops_completed = (
490 | counts_ops_completed.reset_index()
491 | .merge(
492 | full_patient_df_plus_pos[["snapshot_time"]].drop_duplicates(),
493 | how="right",
494 | )
495 | .sort_values("snapshot_time")
496 | ).reset_index(drop=True)
497 |
498 | # For any days with no value, ensure this is changed to a 0
499 | counts_ops_completed["patient"] = counts_ops_completed["patient"].fillna(0)
500 |
501 | # Calculate a running total of this value, which will be used to add the correct value
502 | # to each individual frame
503 | counts_ops_completed["running_total"] = counts_ops_completed.sort_values(
504 | "snapshot_time"
505 | )["patient"].cumsum()
506 | counts_not_avail = counts_not_avail.merge(
507 | counts_ops_completed.rename(columns={"running_total": "completed"}),
508 | how="left",
509 | on="snapshot_time",
510 | )
511 | counts_not_avail["perc_slots_lost"] = counts_not_avail["running_total"] / (
512 | counts_not_avail["running_total"] + counts_not_avail["completed"]
513 | )
514 |
515 | #####################################################
516 | # Adding additional animation traces
517 | #####################################################
518 |
519 | ## First, add each trace so it will show up initially
520 |
521 | # Due to issues detailed in the following SO threads, it's essential to initialize the traces
522 | # outside of the frames argument else they will not show up at all (or show up intermittently)
523 | # https://stackoverflow.com/questions/69867334/multiple-traces-per-animation-frame-in-plotly
524 | # https://stackoverflow.com/questions/69367344/plotly-animating-a-variable-number-of-traces-in-each-frame-in-r
525 | # TODO: More explanation and investigation needed of why sometimes traces do and don't show up after being added in
526 | # via this method. Behaviour seems very inconsistent and not always logical (e.g. order you put traces in to the later
527 | # loop sometimes seems to make a difference but sometimes doesn't; making initial trace transparent sometimes seems to
528 | # stop it showing up when added in the frames but not always; sometimes the initial trace doesn't disappear).
529 |
530 | # Add bed trace in manually to ensure it can be referenced later
531 | fig.add_trace(go.Scatter(x=[100], y=[100]))
532 |
533 | fig.add_trace(fig.data[1])
534 |
535 | # Add animated text trace that gives running total of operations completed
536 | fig.add_trace(
537 | go.Scatter(
538 | x=[100],
539 | y=[30],
540 | text=f"Total Operations Completed: {int(counts_ops_completed.sort_values('snapshot_time')['running_total'][0])}",
541 | mode="text",
542 | textfont=dict(size=20),
543 | opacity=0,
544 | showlegend=False,
545 | )
546 | )
547 |
548 | # Add animated trace giving running total of slots lost and percentage of total slots this represents
549 | fig.add_trace(
550 | go.Scatter(
551 | x=[600],
552 | y=[850],
553 | text="",
554 | # text=f"Total slots lost: {int(counts_not_avail['running_total'][0])} ({counts_not_avail['perc_slots_lost'][0]:.1%})",
555 | mode="text",
556 | textfont=dict(size=20),
557 | # opacity=0,
558 | showlegend=False,
559 | )
560 | )
561 |
562 | # Add trace for the event labels (as these get lost from the animation once we start trying to add other things in,
563 | # so need manually re-adding)
564 | fig.add_trace(
565 | go.Scatter(
566 | x=[pos + 10 for pos in event_position_df["x"].to_list()],
567 | y=event_position_df["y"].to_list(),
568 | mode="text",
569 | name="",
570 | text=event_position_df["label"].to_list(),
571 | textposition="middle right",
572 | hoverinfo="none",
573 | )
574 | )
575 |
576 | # Ensure these all have the right text size
577 | fig.update_traces(textfont_size=14)
578 |
579 | # Now set up the desired subplot layout
580 | sp = make_subplots(
581 | rows=2,
582 | cols=1,
583 | row_heights=[0.85, 0.15],
584 | subplot_titles=("", "Daily lost slots"),
585 | )
586 |
587 | # Overwrite the domain of our original x and y axis with domain from the new axis
588 | fig.layout["xaxis"]["domain"] = sp.layout["xaxis"]["domain"]
589 | fig.layout["yaxis"]["domain"] = sp.layout["yaxis"]["domain"]
590 |
591 | # Add in the attributes for the secondary axis from our subplot
592 | fig.layout["xaxis2"] = sp.layout["xaxis2"]
593 | fig.layout["yaxis2"] = sp.layout["yaxis2"]
594 |
595 | # Final key step - copy over the _grid_ref attribute
596 | # This isn't meant to be something we modify but it's an essential
597 | # part of the subplot code because otherwise plotly doesn't truly know
598 | # how the different subplots are arranged and referenced
599 | fig._grid_ref = sp._grid_ref
600 |
601 | # Add an initial trace to our secondary line chart
602 | fig.add_trace(
603 | go.Scatter(
604 | x=counts_not_avail["snapshot_time"],
605 | y=counts_not_avail["patient_x"],
606 | mode="lines",
607 | showlegend=False,
608 | # name='line',
609 | opacity=0.2,
610 | xaxis="x2",
611 | yaxis="y2",
612 | # We place it in our new subplot using the following line
613 | ),
614 | row=2,
615 | col=1,
616 | )
617 |
618 | ##########################################################
619 | # Now we need to add our traces to each individual frame
620 | ##########################################################
621 | # To work correctly, these need to be provided in the same order as the traces above
622 | for i, frame in enumerate(fig.frames):
623 | frame.data = (
624 | frame.data
625 | +
626 | # bed icons
627 | (fig.data[1],)
628 | +
629 | # Slots used/operations occurred
630 | (
631 | go.Scatter(
632 | x=[100],
633 | y=[30],
634 | text=f"Total Operations Completed: {int(counts_ops_completed.sort_values('snapshot_time')['running_total'][i])}",
635 | mode="text",
636 | textfont=dict(size=20),
637 | showlegend=False,
638 | ),
639 | )
640 | +
641 | # Slots lost
642 | (
643 | go.Scatter(
644 | x=[600],
645 | y=[800],
646 | text=f"Total slots lost: {int(counts_not_avail['running_total'][i])} ({counts_not_avail['perc_slots_lost'][i]:.1%})",
647 | mode="text",
648 | textfont=dict(size=20),
649 | showlegend=False,
650 | ),
651 | )
652 | +
653 | # Position labels
654 | (
655 | go.Scatter(
656 | x=[pos + 10 for pos in event_position_df["x"].to_list()],
657 | y=event_position_df["y"].to_list(),
658 | mode="text",
659 | name="",
660 | text=event_position_df["label"].to_list(),
661 | textposition="middle right",
662 | hoverinfo="none",
663 | ),
664 | )
665 | +
666 | # Line subplot
667 | (
668 | go.Scatter(
669 | x=counts_not_avail["snapshot_time"][0 : i + 1].values,
670 | y=counts_not_avail["patient_x"][0 : i + 1].values,
671 | mode="lines",
672 | # name="line",
673 | # hoverinfo='none',
674 | showlegend=False,
675 | name="line_subplot",
676 | # line=dict(color="#f71707"),
677 | xaxis="x2",
678 | yaxis="y2",
679 | ),
680 | )
681 | # +
682 | # (
683 | # go.Scatter(
684 | # x=counts_ops_completed,
685 | # y=[50],
686 | # text=f"Operations Completed: {int(counts_ops_completed['running_total'][i])}",
687 | # mode='text',
688 | # textfont=dict(size=20),
689 | # showlegend=False,
690 | # ),
691 | # )
692 | )
693 | # + ((fig.data[-1]), ) + ((fig.data[-2]), )
694 |
695 | # Ensure we tell it which traces we are animating
696 | # (as per https://chart-studio.plotly.com/~empet/15243/animating-traces-in-subplotsbr/#/)
697 | for i, frame in enumerate(fig.frames):
698 | # This will ensure it matches the number of traces we have
699 | frame["traces"] = [i for i in range(len(fig.data) + 1)]
700 |
701 | # for frame in fig.frames:
702 | # fig._set_trace_grid_position(frame.data[-1], 2,1)
703 |
704 | # Finally, match these new traces with the text size used elsewhere
705 | fig.update_traces(textfont_size=14)
706 |
707 | fig.update_layout(yaxis2=dict(title="Operations Cancelled Per Day"))
708 |
709 | # sp_test = make_subplots(rows=2, cols=1, row_heights=[0.85, 0.15])
710 |
711 | # test = sp_test.add_trace(go.Scatter(
712 | # x=counts_not_avail['snapshot_time'][0: i+1].values,
713 | # y=counts_not_avail['running_total'][0: i+1].values,
714 | # mode="lines",
715 | # name="line",
716 | # # hoverinfo='none',
717 | # showlegend=False,
718 | # # line=dict(color="#f71707"),
719 | # xaxis='x2',
720 | # yaxis='y2'
721 | # ),row=2, col=1)
722 |
723 | # fig
724 |
725 | # fig.frames[0]
726 |
727 | return fig
728 |
729 | with st.spinner():
730 | fig = generate_animation_fig()
731 |
732 | @st.fragment()
733 | def display_animation_fig(fig):
734 | frame_duration_col, frame_transition_duration_col = st.columns(2)
735 |
736 | frame_duration = frame_duration_col.number_input(
737 | "Choose the frame duration in milliseconds (default is 1500ms)",
738 | 50,
739 | 5000,
740 | 1500,
741 | )
742 | frame_transition_duration = frame_transition_duration_col.number_input(
743 | "Choose the length of the transition between frames in milliseconds (default is 1000ms)",
744 | 0,
745 | 5000,
746 | 1000,
747 | )
748 |
749 | fig.layout.updatemenus[0].buttons[0].args[1]["frame"]["duration"] = (
750 | frame_duration
751 | )
752 | fig.layout.updatemenus[0].buttons[0].args[1]["transition"]["duration"] = (
753 | frame_transition_duration
754 | )
755 |
756 | st.plotly_chart(fig)
757 |
758 | display_animation_fig(fig=fig)
759 |
760 | st.markdown(
761 | """
762 | **Key**:
763 |
764 | 🦵1️⃣: Primary Knee
765 |
766 | 🦵♻️: Revision Knee
767 |
768 | 🕺1️⃣: Primary Hip
769 |
770 | 🕺♻️: Revision Hip
771 |
772 | 🦵✳️: Primary Unicompartment Knee
773 |
774 | An asterisk (*) indicates that the patient has a delayed discharge from the ward.
775 |
776 | The numbers below patients indicate their length of stay.
777 |
778 | Note that the "No Bed Available: Surgery Cancelled" and "Discharged from Hospital after Recovery" stages in the animation are lagged by one day.
779 | For example, on the 2nd of July, this will show the patients who had their surgery cancelled on 1st July or were discharged on 1st July.
780 | These steps are included to make it easier to understand the destinations of different clients, but due to the size of the simulation step shown (1 day) it is difficult to demonstrate this differently.
781 | """
782 | )
783 |
784 | # sp.add_trace(go.Scatter(x=[1, 2, 3], y=[4, 5, 6]),
785 | # row=1, col=1)
786 |
787 | # sp.add_trace(go.Scatter(x=[20, 30, 40], y=[50, 60, 70]),
788 | # row=2, col=1)
789 |
790 | # st.plotly_chart(
791 | # animate_activity_log(
792 | # event_log=event_log,
793 | # event_position_df= event_position_df,
794 | # scenario=args,
795 | # debug_mode=True,
796 | # every_x_time_units=1,
797 | # include_play_button=True,
798 | # gap_between_entities=8,
799 | # gap_between_rows=20,
800 | # plotly_height=700,
801 | # plotly_width=900,
802 | # override_x_max=700,
803 | # override_y_max=550,
804 | # icon_and_text_size=14,
805 | # wrap_queues_at=10,
806 | # step_snapshot_max=50,
807 | # frame_duration=1000,
808 | # # time_display_units="dhm",
809 | # display_stage_labels=True,
810 | # limit_duration=42,
811 | # # add_background_image="https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/resources/Full%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png",
812 | # ), use_container_width=False,
813 | # config = {'displayModeBar': False}
814 | # )
815 |
816 |
817 | # fig.b
818 |
--------------------------------------------------------------------------------