├── .nojekyll ├── __init__.py ├── notes.txt ├── .gitignore ├── examples ├── __init__.py ├── ex_1_simplest_case │ ├── __init__.py │ ├── README.md │ ├── simulation_execution_functions.py │ ├── simulation_summary_functions.py │ └── model_classes.py ├── ex_2_branching_and_optional_paths │ ├── __init__.py │ ├── ed_arrivals.csv │ ├── simulation_execution_functions.py │ └── simulation_summary_functions.py ├── ex_5_community_follow_up │ ├── data │ │ ├── caseload.csv │ │ ├── referrals.csv │ │ ├── shifts.csv │ │ └── partial_pooling.csv │ ├── simulation_execution_functions.py │ └── simulation_summary_functions.py ├── ex_4_community │ ├── data │ │ ├── shifts.csv │ │ ├── referrals.csv │ │ └── partial_pooling.csv │ ├── simulation_execution_functions.py │ ├── simulation_summary_functions.py │ └── model_classes.py ├── simulation_utility_functions.py ├── ex_3_theatres_beds │ ├── simulation_summary_functions.py │ └── simulation_execution_functions.py └── distribution_classes.py ├── resources ├── __init__.py ├── hsma_logo_transparent_background_small.png └── helper_functions.py ├── docs └── 404.html ├── requirements.txt ├── environment └── environment.yml ├── packing_file.py ├── PAGES_SETUP.md ├── .github └── workflows │ └── deploy.yml ├── helper_functions.py ├── LICENSE ├── Introduction.py ├── pages ├── 2_Simple_ED_Forced_Overcrowding.py ├── 1_Simple_ED_Interactive.py ├── 3_Complex_ED_Interactive.py ├── 5_Community_Booking_Model.py └── 4_HEP_Orthopaedic_Surgery.py ├── notes.md └── README.md /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/ex_1_simplest_case/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/ex_2_branching_and_optional_paths/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hsma-programme/simpy_visualisation/HEAD/docs/404.html -------------------------------------------------------------------------------- /resources/hsma_logo_transparent_background_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hsma-programme/simpy_visualisation/HEAD/resources/hsma_logo_transparent_background_small.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib>=3.9.1,<4 2 | numpy>=1.26.2,<2 3 | pandas>=2.0.1,<3 4 | plotly>=5.12.0,<6 5 | simpy==4.0.2,<5 6 | vidigi==1.0.0 7 | streamlit>=1.42.0,<=1.48 8 | arrow 9 | -------------------------------------------------------------------------------- /examples/ex_5_community_follow_up/data/caseload.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 | current_caseload,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 3 | -------------------------------------------------------------------------------- /examples/ex_4_community/data/shifts.csv: -------------------------------------------------------------------------------- 1 | clinic_1,clinic_2,clinic_3,clinic_4,clinic_5,clinic_6,clinic_7,clinic_8,clinic_9,clinic_10,clinic_11 2 | 4,1,7,3,1,5,3,3,5,11,1 3 | 4,1,6,3,1,5,3,3,5,10,0 4 | 4,1,7,3,2,5,4,3,5,11,1 5 | 4,1,6,3,1,5,3,3,5,10,1 6 | 4,1,6,2,1,4,3,2,4,10,0 7 | 0,0,0,0,0,0,0,0,0,0,0 8 | 0,0,0,0,0,0,0,0,0,0,0 9 | -------------------------------------------------------------------------------- /environment/environment.yml: -------------------------------------------------------------------------------- 1 | name: hsma_des_model_library 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.11.4 6 | - pip 7 | - pip: 8 | - matplotlib>=3.9.1,<4 9 | - numpy>=1.26.2,<2 10 | - pandas>=2.0.1,<3 11 | - plotly>=5.12.0,<6 12 | - simpy==4.0.2,<5 13 | - vidigi==0.0.4 14 | - streamlit>=1.36.0,<2 15 | -------------------------------------------------------------------------------- /examples/ex_4_community/data/referrals.csv: -------------------------------------------------------------------------------- 1 | clinic,prop,referred_out,dna 2 | 1,0.111491288098121,0.456,0.2 3 | 2,0.026346265199768,0.428,0.25 4 | 3,0.196004632310365,0.489,0.25 5 | 3,0.062220350581671,0.296,0.2 6 | 4,0.025451387061115,0.275,0.23 7 | 5,0.081302310891193,0.091,0.21 8 | 6,0.05714060114755,0.162,0.24 9 | 7,0.04927093751645,0.129,0.17 10 | 8,0.125625098699795,0.41,0.21 11 | 9,0.255356108859294,0.361,0.3 12 | 10,0.009791019634679,0.123,0.2 13 | -------------------------------------------------------------------------------- /examples/ex_4_community/simulation_execution_functions.py: -------------------------------------------------------------------------------- 1 | from examples.ex_4_community.model_classes import AssessmentReferralModel 2 | 3 | def single_run(args, rep=0): 4 | ''' 5 | Perform as single run of the model and resturn results as a tuple. 6 | ''' 7 | model = AssessmentReferralModel(args) 8 | model.run() 9 | model.process_run_results() 10 | 11 | return model.results_all, model.results_low, model.results_high, model.event_log 12 | -------------------------------------------------------------------------------- /examples/ex_5_community_follow_up/data/referrals.csv: -------------------------------------------------------------------------------- 1 | clinic,prop,referred_out,dna 2 | 1,1,0.12,0.2 3 | 2,0.0,0.428,0.25 4 | 3,0.0,0.489,0.25 5 | 4,0.0,0.296,0.2 6 | 5,0.0,0.275,0.23 7 | 6,0.0,0.091,0.21 8 | 7,0.0,0.162,0.24 9 | 8,0.0,0.129,0.17 10 | 9,0.0,0.41,0.21 11 | 10,0.0,0.361,0.3 12 | 11,0.0,0.123,0.2 13 | 12,0.0,0.123,0.2 14 | 13,0.0,0.123,0.2 15 | 14,0.0,0.123,0.2 16 | 15,0.0,0.123,0.2 17 | 16,0.0,0.123,0.2 18 | 17,0.0,0.123,0.2 19 | 18,0.0,0.123,0.2 20 | 19,0.0,0.123,0.2 21 | 20,0.0,0.123,0.2 22 | -------------------------------------------------------------------------------- /examples/ex_2_branching_and_optional_paths/ed_arrivals.csv: -------------------------------------------------------------------------------- 1 | period,arrival_rate 2 | 6AM-7AM,2.36666666666667 3 | 7AM-8AM,2.8 4 | 8AM-9AM,8.83333333333333 5 | 9AM-10AM,10.4333333333333 6 | 10AM-11AM,14.8 7 | 11AM-12PM,26.2666666666667 8 | 12PM-1PM,31.4 9 | 1PM-2PM,18.0666666666667 10 | 2PM-3PM,16.4666666666667 11 | 3PM-4PM,12.0333333333333 12 | 4PM-5PM,11.6 13 | 5PM-6PM,28.8666666666667 14 | 6PM-7PM,18.0333333333333 15 | 7PM-8PM,11.5 16 | 8PM-9PM,5.3 17 | 9PM-10PM,4.06666666666667 18 | 10PM-11PM,2.2 19 | 11PM-12AM,2.1 20 | -------------------------------------------------------------------------------- /examples/ex_5_community_follow_up/data/shifts.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 | 0,5,4,4,0,5,3,3,5,5,5,0,5,4,4,0,5,3,3,5 3 | 0,3,5,4,5,5,3,3,5,5,5,0,3,5,4,5,5,3,3,5 4 | 4,3,5,5,3,5,4,3,5,2,2,4,3,5,5,3,5,4,3,5 5 | 4,5,2,1,5,5,3,3,5,5,0,4,5,2,1,5,5,3,3,5 6 | 4,0,1,0,5,4,3,1,4,5,0,4,0,1,0,5,4,3,1,4 7 | 5,0,0,0,3,0,0,0,0,0,0,5,0,0,0,3,0,0,0,0 8 | 0,5,0,3,0,0,0,0,0,0,0,0,5,0,3,0,0,0,0,0 9 | -------------------------------------------------------------------------------- /examples/ex_1_simplest_case/README.md: -------------------------------------------------------------------------------- 1 | This example shows a very simple pathway: 2 | - patient arrivals are generated 3 | - the patient uses a resource for a variable period of time 4 | - the patient exits the system after this step 5 | 6 | This demonstrates the use of custom simpy resources wthin a simpy store, and also all of the key logging steps required. 7 | 8 | All key model logic is contained within `model_classes.py`. 9 | 10 | The corresponding page for this in the Streamlit app is `pages/1_Simple_ED_Interactive``. 11 | -------------------------------------------------------------------------------- /examples/ex_4_community/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 2 | clinic_1,1,1,1,0,0,0,0,0,0,0,0 3 | clinic_2,1,1,1,0,0,0,0,0,0,0,0 4 | clinic_3,1,1,1,1,1,0,0,0,1,0,0 5 | clinic_4,0,0,0,1,1,1,0,0,0,0,0 6 | clinic_5,0,0,0,1,1,1,0,0,0,0,0 7 | clinic_6,0,0,0,1,1,1,1,0,0,0,0 8 | clinic_7,0,0,0,0,0,1,1,0,0,0,0 9 | clinic_8,0,0,0,0,0,0,0,1,1,1,1 10 | clinic_9,0,0,1,0,0,0,0,1,1,1,0 11 | clinic_10,0,0,0,0,0,0,0,0,1,1,0 12 | clinic_11,0,0,0,0,0,0,0,1,0,0,1 13 | -------------------------------------------------------------------------------- /examples/ex_5_community_follow_up/simulation_execution_functions.py: -------------------------------------------------------------------------------- 1 | from examples.ex_5_community_follow_up.model_classes import AssessmentReferralModel 2 | 3 | def single_run(args, rep=0): 4 | ''' 5 | Perform as single run of the model and resturn results as a tuple. 6 | ''' 7 | model = AssessmentReferralModel(args) 8 | model.run() 9 | model.process_run_results() 10 | 11 | return model.results_all, model.results_low, model.results_high, model.event_log, model.bookings, model.available_slots, model.daily_caseload_snapshots, model.daily_waiting_for_booking_snapshots, model.results_daily_arrivals 12 | -------------------------------------------------------------------------------- /packing_file.py: -------------------------------------------------------------------------------- 1 | from stlitepack import pack, setup_github_pages 2 | from stlitepack.pack import list_files_in_folders 3 | 4 | files_to_link = list_files_in_folders(["examples", "resources"], recursive=True) 5 | 6 | print(files_to_link) 7 | 8 | pack( 9 | "Introduction.py", 10 | extra_files_to_link=files_to_link, 11 | prepend_github_path="Bergam0t/simpy_visualisation", 12 | run_preview_server=True, 13 | requirements=[ 14 | "matplotlib", 15 | "numpy", 16 | "pandas", 17 | "plotly", 18 | "simpy==4.0.2", 19 | "vidigi==1.0.0", 20 | "arrow", 21 | "setuptools", 22 | ], 23 | ) 24 | 25 | setup_github_pages() 26 | -------------------------------------------------------------------------------- /PAGES_SETUP.md: -------------------------------------------------------------------------------- 1 | [stlitepack] Github Pages Workflow mode selected. 2 | 3 | To complete setup: 4 | 1. **BEFORE COMMITING THE NEWLY CREATED FILES**, Go to your repository **Settings -> Pages**. 5 | 2. Under "Build and deployment", set: 6 | - Source: **Github Actions** 7 | 3. **NOW** commit the following files: 8 | - the deploy.yml file that has been created in .github/workflows 9 | - the `index.html` that was created in the specified folder in your repository 10 | - the `404.html` and `.nojekyll` files that have been created in the root of your repository 11 | 4. Visit your deployed app at https://your-github-username.github.io/your-repo-name/ 12 | - note that it may take a few minutes for the app to finish deploying 13 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - 'docs/index.html' 10 | 11 | 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Pages 25 | uses: actions/configure-pages@v4 26 | 27 | - name: Upload artifact 28 | uses: actions/upload-pages-artifact@v3 29 | with: 30 | path: docs 31 | 32 | - name: Deploy to GitHub Pages 33 | id: deployment 34 | uses: actions/deploy-pages@v4 35 | -------------------------------------------------------------------------------- /examples/ex_4_community/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 | summary_frame = pd.concat([pd.DataFrame(results_all).describe(), 23 | pd.DataFrame(results_low).describe(), 24 | pd.DataFrame(results_high).describe()], 25 | axis=1) 26 | summary_frame.columns = ['all', 'low_pri', 'high_pri'] 27 | return summary_frame 28 | -------------------------------------------------------------------------------- /helper_functions.py: -------------------------------------------------------------------------------- 1 | import streamlit.components.v1 as components 2 | 3 | # def d2(code: str) -> None: 4 | # components.html( 5 | # f""" 6 | #
 7 | #             {code}
 8 | #         
9 | 10 | # 14 | # """ 15 | # ) 16 | 17 | # From https://discuss.streamlit.io/t/st-markdown-does-not-render-mermaid-graphs/25576/3 18 | def mermaid(code: str, height=600, width=None) -> None: 19 | components.html( 20 | f""" 21 | 22 | 23 |
24 |             {code}
25 |         
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 | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7951080.svg)](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 | --------------------------------------------------------------------------------