diff --git a/Introduction.py b/Introduction.py index b583e36..9866025 100644 --- a/Introduction.py +++ b/Introduction.py @@ -22,7 +22,7 @@ """ This is a discrete event simulation playground based on the Monks et al (2022), which is itself an implementation of the Treatment Centre Model from Nelson (2013). -By working through the pages on the left in order, you will +By working through the pages on the left in order, you will - see how a discrete event simulation builds from simple beginnings up to the point of being able to model a complex system - understand the impact of variability and randomness on systems - have a go at changing parameters to find the best configuration that balances the average use of resources (like treatment bays or nurses) and the average time a patient will wait at each step of the process. @@ -38,20 +38,20 @@ %%{ init: { 'theme': 'base', 'themeVariables': {'lineColor': '#b4b4b4'} } }%% flowchart LR A[Arrival] --> B{Trauma or non-trauma} - B --> B1{Trauma Pathway} + B --> B1{Trauma Pathway} B --> B2{Non-Trauma Pathway} - + B1 --> C[Stabilisation] C --> E[Treatment] - + B2 --> D[Registration] D --> G[Examination] G --> H[Treat?] - H ----> F + H ----> F H --> I[Non-Trauma Treatment] - I --> F + I --> F C -.-> Z([Trauma Room\nRESOURCE]) Z -.-> C @@ -90,8 +90,8 @@ class I,V ZZ4; """ ## References -1. *Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) Open Science for Computer Simulation* -2. *Nelson. B.L. (2013). [Foundations and methods of stochastic simulation](https://www.amazon.co.uk/Foundations-Methods-Stochastic-Simulation-International/dp/1461461596/ref=sr_1_1?dchild=1&keywords=foundations+and+methods+of+stochastic+simulation&qid=1617050801&sr=8-1). Springer.* +1. *Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) Open Science for Computer Simulation*; [Repository Link](https://github.com/TomMonks/treatment-centre-sim/tree/main) +2. *Nelson. B.L. (2013). [Foundations and methods of stochastic simulation](https://www.amazon.co.uk/Foundations-Methods-Stochastic-Simulation-International/dp/1461461596/ref=sr_1_1?dchild=1&keywords=foundations+and+methods+of+stochastic+simulation&qid=1617050801&sr=8-1). Springer.* 3. https://health-data-science-or.github.io/simpy-streamlit-tutorial/ """ -) \ No newline at end of file +) diff --git a/index.html b/index.html index 460cc4d..25317cb 100644 --- a/index.html +++ b/index.html @@ -47,7 +47,7 @@ """ This is a discrete event simulation playground based on the Monks et al (2022), which is itself an implementation of the Treatment Centre Model from Nelson (2013). -By working through the pages on the left in order, you will +By working through the pages on the left in order, you will - see how a discrete event simulation builds from simple beginnings up to the point of being able to model a complex system - understand the impact of variability and randomness on systems - have a go at changing parameters to find the best configuration that balances the average use of resources (like treatment bays or nurses) and the average time a patient will wait at each step of the process. @@ -62,20 +62,20 @@ %%{ init: { 'theme': 'base', 'themeVariables': {'lineColor': '#b4b4b4'} } }%% flowchart LR A[Arrival] --> B{Trauma or non-trauma} - B --> B1{Trauma Pathway} + B --> B1{Trauma Pathway} B --> B2{Non-Trauma Pathway} - + B1 --> C[Stabilisation] C --> E[Treatment] - + B2 --> D[Registration] D --> G[Examination] G --> H[Treat?] - H ----> F + H ----> F H --> I[Non-Trauma Treatment] - I --> F + I --> F C -.-> Z([Trauma Room\nRESOURCE]) Z -.-> C @@ -114,10 +114,10 @@ """ ## References -1. *Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) Open Science for Computer Simulation* -2. *Nelson. B.L. (2013). [Foundations and methods of stochastic simulation](https://www.amazon.co.uk/Foundations-Methods-Stochastic-Simulation-International/dp/1461461596/ref=sr_1_1?dchild=1&keywords=foundations+and+methods+of+stochastic+simulation&qid=1617050801&sr=8-1). Springer.* +1. *Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) Open Science for Computer Simulation*; [Repository Link](https://github.com/TomMonks/treatment-centre-sim/tree/main) +2. *Nelson. B.L. (2013). [Foundations and methods of stochastic simulation](https://www.amazon.co.uk/Foundations-Methods-Stochastic-Simulation-International/dp/1461461596/ref=sr_1_1?dchild=1&keywords=foundations+and+methods+of+stochastic+simulation&qid=1617050801&sr=8-1). Springer.* 3. https://health-data-science-or.github.io/simpy-streamlit-tutorial/ -""" +""" ) @@ -144,15 +144,15 @@ "helper_functions.py": { url: "https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/helper_functions.py" }, - + "distribution_classes.py": { url: "https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/distribution_classes.py" }, - + "model_classes.py": { url: "https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/model_classes.py" }, - + "style.css": { url: "https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/style.css" }, @@ -176,11 +176,11 @@ "home/.streamlit/secrets.toml": { url: "https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/.streamlit/secrets.toml" } - + }, }, document.getElementById("root") ); - \ No newline at end of file + diff --git a/output_animation_functions.py b/output_animation_functions.py index 2925379..80523d6 100644 --- a/output_animation_functions.py +++ b/output_animation_functions.py @@ -1,124 +1,152 @@ -import plotly.express as px -import plotly.graph_objects as go +import datetime as dt +import gc +import time import pandas as pd import numpy as np -import datetime as dt +import plotly.express as px +import plotly.graph_objects as go + +def reshape_for_animations(event_log, + every_x_time_units=10, + limit_duration=10*60*24, + step_snapshot_max=50, + debug_mode=False): + patient_dfs = [] + + pivoted_log = event_log.pivot_table(values="time", + index=["patient","event_type","pathway"], + columns="event").reset_index() + + #TODO: Add in behaviour for if limit_duration is None + + ################################################################################ + # Iterate through every matching minute + # and generate snapshot df of position of any patients present at that moment + ################################################################################ + for minute in range(limit_duration): + # print(minute) + # Get patients who arrived before the current minute and who left the system after the current minute + # (or arrived but didn't reach the point of being seen before the model run ended) + # When turning this into a function, think we will want user to pass + # 'first step' and 'last step' or something similar + # and will want to reshape the event log for this so that it has a clear start/end regardless + # of pathway (move all the pathway stuff into a separate column?) + + # Think we maybe need a pathway order and pathway precedence column + # But what about shared elements of each pathway? + if minute % every_x_time_units == 0: + try: + # Work out which patients - if any - were present in the simulation at the current time + # They will have arrived at or before the minute in question, and they will depart at + # or after the minute in question, or never depart during our model run + # (which can happen if they arrive towards the end, or there is a bottleneck) + current_patients_in_moment = pivoted_log[(pivoted_log['arrival'] <= minute) & + ( + (pivoted_log['depart'] >= minute) | + (pivoted_log['depart'].isnull() ) + )]['patient'].values + except KeyError: + current_patients_in_moment = None + + # If we do have any patients, they will have been passed as a list + # so now just filter our event log down to the events these patients have been + # involved in + if current_patients_in_moment is not None: + # Grab just those clients from the filtered log (the unpivoted version) + # Filter out any events that have taken place after the minute we are interested in + + patient_minute_df = event_log[ + (event_log['patient'].isin(current_patients_in_moment)) & + (event_log['time'] <= minute) + ] + # Each person can only be in a single place at once, and we have filtered out + # events that occurred later than the current minute, so filter out any events + # then just take the latest event that has taken place for each client + most_recent_events_minute_ungrouped = patient_minute_df \ + .reset_index(drop=False) \ + .sort_values(['time', 'index'], ascending=True) \ + .groupby(['patient']) \ + .tail(1) + + # Now rank patients within a given event by the order in which they turned up to that event + most_recent_events_minute_ungrouped['rank'] = most_recent_events_minute_ungrouped \ + .groupby(['event'])['index'] \ + .rank(method='first') -def reshape_for_animations(full_event_log, every_x_minutes=10): - minute_dfs = list() - patient_dfs = list() - - for rep in range(1, max(full_event_log['rep'])+1): - # print("Rep {}".format(rep)) - # Start by getting data for a single rep - filtered_log_rep = full_event_log[full_event_log['rep'] == rep].drop('rep', axis=1) - pivoted_log = filtered_log_rep.pivot_table(values="time", - index=["patient","event_type","pathway"], - columns="event").reset_index() - - for minute in range(10*60*24): - # print(minute) - # Get patients who arrived before the current minute and who left the system after the current minute - # (or arrived but didn't reach the point of being seen before the model run ended) - # When turning this into a function, think we will want user to pass - # 'first step' and 'last step' or something similar - # and will want to reshape the event log for this so that it has a clear start/end regardless - # of pathway (move all the pathway stuff into a separate column?) - - # Think we maybe need a pathway order and pathway precedence column - # But what about shared elements of each pathway? - if minute % every_x_minutes == 0: - - try: - current_patients_in_moment = pivoted_log[(pivoted_log['arrival'] <= minute) & - ( - (pivoted_log['depart'] >= minute) | - (pivoted_log['depart'].isnull() ) - )]['patient'].values - except KeyError: - current_patients_in_moment = None - if current_patients_in_moment is not None: - patient_minute_df = filtered_log_rep[filtered_log_rep['patient'].isin(current_patients_in_moment)] - # print(len(patient_minute_df)) - # Grab just those clients from the filtered log (the unpivoted version) - # Each person can only be in a single place at once, so filter out any events - # that have taken place after the minute - # then just take the latest event that has taken place for each client - # most_recent_events_minute = patient_minute_df[patient_minute_df['time'] <= minute] \ - # .sort_values('time', ascending=True) \ - # .groupby(['patient',"event_type","pathway"]) \ - # .tail(1) - - most_recent_events_minute_ungrouped = patient_minute_df[patient_minute_df['time'] <= minute].reset_index() \ - .sort_values(['time', 'index'], ascending=True) \ - .groupby(['patient']) \ - .tail(1) - - patient_dfs.append(most_recent_events_minute_ungrouped.assign(minute=minute, rep=rep)) - - # Now count how many people are in each state - # CHECK - I THINK THIS IS PROBABLY DOUBLE COUNTING PEOPLE BECAUSE OF THE PATHWAY AND EVENT TYPE. JUST JOIN PATHWAY/EVENT TYPE BACK IN INSTEAD? - state_counts_minute = most_recent_events_minute_ungrouped[['event']].value_counts().rename("count").reset_index().assign(minute=minute, rep=rep) - - minute_dfs.append(state_counts_minute) - - - minute_counts_df = pd.concat(minute_dfs).merge(filtered_log_rep[['event','event_type', 'pathway']].drop_duplicates().reset_index(drop=True), on="event") - full_patient_df = pd.concat(patient_dfs).sort_values(["rep", "minute", "event"]) + most_recent_events_minute_ungrouped['max'] = most_recent_events_minute_ungrouped.groupby('event')['rank'] \ + .transform('max') - # Add a final exit step for each client - final_step = full_patient_df.sort_values(["rep", "patient", "minute"], ascending=True).groupby(["rep", "patient"]).tail(1) - final_step['minute'] = final_step['minute'] + every_x_minutes - final_step['event'] = "exit" - # final_step['event_type'] = "arrival_departure" + most_recent_events_minute_ungrouped = most_recent_events_minute_ungrouped[ + most_recent_events_minute_ungrouped['rank'] <= (step_snapshot_max + 1) + ].copy() - full_patient_df = full_patient_df.append(final_step) + maximum_row_per_event_df = most_recent_events_minute_ungrouped[ + most_recent_events_minute_ungrouped['rank'] == float(step_snapshot_max + 1) + ].copy() - minute_counts_df_pivoted = minute_counts_df.pivot_table(values="count", - index=["minute", "rep", "event_type", "pathway"], - columns="event").reset_index().fillna(0) + maximum_row_per_event_df['additional'] = '' - minute_counts_df_complete = minute_counts_df_pivoted.melt(id_vars=["minute", "rep","event_type","pathway"]) + if len(maximum_row_per_event_df) > 0: + maximum_row_per_event_df['additional'] = maximum_row_per_event_df['max'] - maximum_row_per_event_df['rank'] + most_recent_events_minute_ungrouped = pd.concat( + [most_recent_events_minute_ungrouped[most_recent_events_minute_ungrouped['rank'] != float(step_snapshot_max + 1)], + maximum_row_per_event_df], + ignore_index=True + ) - return { - "minute_counts_df": minute_counts_df, - "minute_counts_df_complete": minute_counts_df_complete, - "full_patient_df": full_patient_df.sort_values(["rep", "minute", "event"]) - - } + # Add this dataframe to our list of dataframes, and then return to the beginning + # of the loop and do this for the next minute of interest until we reach the end + # of the period of interest + patient_dfs.append(most_recent_events_minute_ungrouped + .drop(columns='max') + .assign(minute=minute)) + if debug_mode: + print(f'Iteration through minute-by-minute logs complete {time.strftime("%H:%M:%S", time.localtime())}') + full_patient_df = (pd.concat(patient_dfs, ignore_index=True)).reset_index(drop=True) -# ['TRAUMA_triage_wait_begins', 'TRAUMA_triage_begins', 'TRAUMA_triage_complete', -# 'TRAUMA_stabilisation_wait_begins', 'TRAUMA_stabilisation_begins', 'TRAUMA_stabilisation_complete', -# 'TRAUMA_treatment_wait_begins', 'TRAUMA_treatment_begins', 'TRAUMA_treatment_wait_begins' -# ] + if debug_mode: + print(f'Snapshot df concatenation complete at {time.strftime("%H:%M:%S", time.localtime())}') + del patient_dfs + gc.collect() -def animate_activity_log( + # Add a final exit step for each client + # This is helpful as it ensures all patients are visually seen to exit rather than + # just disappearing after their final step + # It makes it easier to track the split of people going on to an optional step when + # this step is at the end of the pathway + # TODO: Fix so that everyone doesn't automatically exit at the end of the simulation run + final_step = full_patient_df.sort_values(["patient", "minute"], ascending=True) \ + .groupby(["patient"]) \ + .tail(1) + + final_step['minute'] = final_step['minute'] + every_x_time_units + final_step['event'] = "exit" + + full_patient_df = pd.concat([full_patient_df, final_step], ignore_index=True) + + del final_step + gc.collect() + + return full_patient_df.sort_values(["minute", "event"]).reset_index(drop=True) + +def generate_animation_df( full_patient_df, event_position_df, - scenario, - rep=1, - plotly_height=900, - plotly_width=None, - wrap_queues_at=None, - include_play_button=True, - return_df_only=False, - add_background_image=None, - display_stage_labels=True, - icon_and_text_size=24, - override_x_max=None, - override_y_max=None, - time_display_units=None, - setup_mode=False, - frame_duration=400, #milliseconds - frame_transition_duration=600 #milliseconds - ): + wrap_queues_at=20, + step_snapshot_max=50, + gap_between_entities=10, + gap_between_resources=10, + gap_between_rows=30, + debug_mode=False +): """_summary_ Args: full_patient_df (pd.Dataframe): + output of reshape_for_animation() event_position_dicts (pd.Dataframe): dataframe with three cols - event, x and y @@ -143,45 +171,53 @@ def animate_activity_log( # Filter to only a single replication - # TODO: Remove this from this function, and instead write a test - # to ensure that no patient ID appears in multiple places at a single minute + # TODO: Write a test to ensure that no patient ID appears in multiple places at a single minute # and return an error if it does so - # Move the step of ensuring there's only a single model run involved to outside - # of this function as it's not really its job. - - full_patient_df = full_patient_df[full_patient_df['rep'] == rep].sort_values([ - 'event','minute','time' - ]) - # full_patient_df['count'] = full_patient_df.groupby(['event','minute','rep'])['minute'] \ - # .transform('count') - # Order patients within event/minute/rep to determine their eventual position in the line - full_patient_df['rank'] = full_patient_df.groupby(['event','minute','rep'])['minute'] \ + full_patient_df['rank'] = full_patient_df.groupby(['event','minute'])['minute'] \ .rank(method='first') full_patient_df_plus_pos = full_patient_df.merge(event_position_df, on="event", how='left') \ - .sort_values(["rep", "event", "minute", "time"]) + .sort_values(["event", "minute", "time"]) # Determine the position for any resource use steps resource_use = full_patient_df_plus_pos[full_patient_df_plus_pos['event_type'] == "resource_use"].copy() - resource_use['y_final'] = resource_use['y'] - resource_use['x_final'] = resource_use['x'] - resource_use['resource_id']*10 + # resource_use['y_final'] = resource_use['y'] + + if len(resource_use) > 0: + resource_use = resource_use.rename(columns={"y": "y_final"}) + resource_use['x_final'] = resource_use['x'] - resource_use['resource_id'] * gap_between_resources # Determine the position for any queuing steps - queues = full_patient_df_plus_pos[full_patient_df_plus_pos['event_type']=='queue'] - queues['y_final'] = queues['y'] - queues['x_final'] = queues['x'] - queues['rank']*10 + queues = full_patient_df_plus_pos[full_patient_df_plus_pos['event_type']=='queue'].copy() + # queues['y_final'] = queues['y'] + queues = queues.rename(columns={"y": "y_final"}) + queues['x_final'] = queues['x'] - queues['rank'] * gap_between_entities # If we want people to wrap at a certain queue length, do this here # They'll wrap at the defined point and then the queue will start expanding upwards # from the starting row if wrap_queues_at is not None: - queues['row'] = np.floor((queues['rank']) / (wrap_queues_at+1)) - queues['x_final'] = queues['x_final'] + (wrap_queues_at*queues['row']*10) - queues['y_final'] = queues['y_final'] + (queues['row'] * 30) + queues['row'] = np.floor((queues['rank'] - 1) / (wrap_queues_at)) + queues['x_final'] = queues['x_final'] + (wrap_queues_at * queues['row'] * gap_between_entities) + gap_between_entities + queues['y_final'] = queues['y_final'] + (queues['row'] * gap_between_rows) + + queues['x_final'] = np.where(queues['rank'] != step_snapshot_max + 1, + queues['x_final'], + queues['x_final'] - (gap_between_entities * (wrap_queues_at/2))) + + + if len(resource_use) > 0: + full_patient_df_plus_pos = pd.concat([queues, resource_use], ignore_index=True) + del resource_use, queues + else: + full_patient_df_plus_pos = queues.copy() + del queues + - full_patient_df_plus_pos = pd.concat([queues, resource_use]) + if debug_mode: + print(f'Placement dataframe finished construction at {time.strftime("%H:%M:%S", time.localtime())}') # full_patient_df_plus_pos['icon'] = '🙍' @@ -216,9 +252,49 @@ def animate_activity_log( pd.DataFrame({'patient':list(individual_patients), 'icon':full_icon_list}), on="patient") + + if 'additional' in full_patient_df_plus_pos.columns: + exceeded_snapshot_limit = full_patient_df_plus_pos[full_patient_df_plus_pos['additional'].notna()].copy() + exceeded_snapshot_limit['icon'] = exceeded_snapshot_limit['additional'].apply(lambda x: f"+ {int(x):5d} more") + full_patient_df_plus_pos = pd.concat( + [ + full_patient_df_plus_pos[full_patient_df_plus_pos['additional'].isna()], exceeded_snapshot_limit + ], + ignore_index=True + ) + + return full_patient_df_plus_pos + - if return_df_only: - return full_patient_df_plus_pos + +def generate_animation( + full_patient_df_plus_pos, + event_position_df, + scenario=None, + plotly_height=900, + plotly_width=None, + include_play_button=True, + add_background_image=None, + display_stage_labels=True, + icon_and_text_size=24, + override_x_max=None, + override_y_max=None, + time_display_units=None, + start_date=None, + resource_opacity=0.8, + custom_resource_icon=None, + gap_between_resources=10, + setup_mode=False, + frame_duration=400, #milliseconds + frame_transition_duration=600, #milliseconds + debug_mode=False +): + """_summary_ + + Args: + full_patient_df_plus_post (pd.Dataframe): + generate_animation_df() + """ if override_x_max is not None: x_max = override_x_max @@ -248,16 +324,31 @@ def animate_activity_log( full_patient_df_plus_pos['minute'] = full_patient_df_plus_pos['minute'].apply( lambda x: dt.datetime.strftime(x, '%Y-%m-%d %H:%M') ) + if time_display_units == "d": + if start_date is None: + full_patient_df_plus_pos['minute'] = dt.date.today() + pd.DateOffset(days=165) + pd.TimedeltaIndex(full_patient_df_plus_pos['minute'], unit='d') + else: + full_patient_df_plus_pos['minute'] = dt.datetime.strptime(start_date, "%Y-%m-%d") + pd.TimedeltaIndex(full_patient_df_plus_pos['minute'], unit='d') + + full_patient_df_plus_pos['minute_display'] = full_patient_df_plus_pos['minute'].apply( + lambda x: dt.datetime.strftime(x, '%A %d %B %Y') + ) + full_patient_df_plus_pos['minute'] = full_patient_df_plus_pos['minute'].apply( + lambda x: dt.datetime.strftime(x, '%Y-%m-%d') + ) else: full_patient_df_plus_pos['minute_display'] = full_patient_df_plus_pos['minute'] - # full_patient_df_plus_pos['size'] = 24 - # We are effectively making use of an animated plotly express scatterploy # to do all of the heavy lifting # Because of the way plots animate in this, it deals with all of the difficulty # of paths between individual positions - so we just have to tell it where to put # people at each defined step of the process, and the scattergraph will move them + if scenario is not None: + hovers = ["patient", "pathway", "time", "minute", "resource_id"] + else: + hovers = ["patient", "pathway", "time", "minute"] + fig = px.scatter( full_patient_df_plus_pos.sort_values('minute'), @@ -269,24 +360,14 @@ def animate_activity_log( # Important to group by patient here animation_group="patient", text="icon", - # Can't have colours because it causes bugs with - # lots of points failing to appear - #color="event", hover_name="event", - hover_data=["patient", "pathway", "time", "minute", "resource_id"], - # The approach of putting in the people as symbols didn't work - # Went with making emoji text labels instead - this works better! - # But leaving in as a reminder that the symbol approach doens't work. - #symbol="rep", - #symbol_sequence=["⚽"], - #symbol_map=dict(rep_choice = "⚽"), + hover_data=hovers, range_x=[0, x_max], range_y=[0, y_max], height=plotly_height, width=plotly_width, # This sets the opacity of the points that sit behind opacity=0 - # size="size" ) # Now add labels identifying each stage (optional - can either be used @@ -308,39 +389,66 @@ def animate_activity_log( # represent our people! fig.update_traces(textfont_size=icon_and_text_size) - # Finally add in icons to indicate the available resources + ############################################# + # Add in icons to indicate the available resources + ############################################# + # Make an additional dataframe that has one row per resource type # Then, starting from the initial position, make that many large circles # make them semi-transparent or you won't see the people using them! - events_with_resources = event_position_df[event_position_df['resource'].notnull()].copy() - events_with_resources['resource_count'] = events_with_resources['resource'].apply(lambda x: getattr(scenario, x)) - - events_with_resources = events_with_resources.join(events_with_resources.apply( - lambda r: pd.Series({'x_final': [r['x']-(10*(i+1)) for i in range(r['resource_count'])]}), axis=1).explode('x_final'), - how='right') - - # This just adds an additional scatter trace that creates large dots - # that represent the individual resources - fig.add_trace(go.Scatter( - x=events_with_resources['x_final'].to_list(), - # Place these slightly below the y position for each entity - # that will be using the resource - y=[i-10 for i in events_with_resources['y'].to_list()], - mode="markers", - # Define what the marker will look like - marker=dict( - color='LightSkyBlue', - size=15), - opacity=0.8, - hoverinfo='none' - )) - + if scenario is not None: + events_with_resources = event_position_df[event_position_df['resource'].notnull()].copy() + events_with_resources['resource_count'] = events_with_resources['resource'].apply(lambda x: getattr(scenario, x)) + + events_with_resources = events_with_resources.join(events_with_resources.apply( + lambda r: pd.Series({'x_final': [r['x']-(gap_between_resources*(i+1)) for i in range(r['resource_count'])]}), axis=1).explode('x_final'), + how='right') + + # This just adds an additional scatter trace that creates large dots + # that represent the individual resources + #TODO: Add ability to pass in 'icon' column as part of the event_position_df that + # can then be used to provide custom icons per resource instead of a single custom + # icon for all resources + if custom_resource_icon is not None: + fig.add_trace(go.Scatter( + x=events_with_resources['x_final'].to_list(), + # Place these slightly below the y position for each entity + # that will be using the resource + y=[i-10 for i in events_with_resources['y'].to_list()], + mode="markers+text", + text=custom_resource_icon, + # Make the actual marker invisible + marker=dict(opacity=0), + # Set opacity of the icon + opacity=0.8, + hoverinfo='none' + )) + else: + fig.add_trace(go.Scatter( + x=events_with_resources['x_final'].to_list(), + # Place these slightly below the y position for each entity + # that will be using the resource + y=[i-10 for i in events_with_resources['y'].to_list()], + mode="markers", + # Define what the marker will look like + marker=dict( + color='LightSkyBlue', + size=15), + opacity=resource_opacity, + hoverinfo='none' + )) + + ############################################# # Optional step to add a background image + ############################################# + # This can help to better visualise the layout/structure of a pathway # Simple FOSS tool for creating these background images is draw.io + # Ideally your queueing steps should always be ABOVE your resource use steps # as this then results in people nicely flowing from the front of the queue # to the next stage + if add_background_image is not None: fig.add_layout_image( dict( @@ -359,8 +467,8 @@ def animate_activity_log( ) # We don't need any gridlines or tickmarks for the final output, so remove - # However, can be useful for the initial setup phase of the outputs, so give the - # option to inlcude + # However, can be useful for the initial setup phase of the outputs, so give + # the option to inlcude if not setup_mode: fig.update_xaxes(showticklabels=False, showgrid=False, zeroline=False, # Prevent zoom @@ -383,24 +491,88 @@ def animate_activity_log( # Adjust speed of animation fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration'] = frame_duration fig.layout.updatemenus[0].buttons[0].args[1]['transition']['duration'] = frame_transition_duration + if debug_mode: + print(f'Output animation generation complete at {time.strftime("%H:%M:%S", time.localtime())}') return fig +def animate_activity_log( + event_log, + event_position_df, + scenario, + every_x_time_units=10, + wrap_queues_at=20, + step_snapshot_max=50, + limit_duration=10*60*24, + plotly_height=900, + plotly_width=None, + include_play_button=True, + add_background_image=None, + display_stage_labels=True, + icon_and_text_size=24, + gap_between_entities=10, + gap_between_rows=30, + gap_between_resources=10, + resource_opacity=0.8, + custom_resource_icon=None, + override_x_max=None, + override_y_max=None, + time_display_units=None, + setup_mode=False, + frame_duration=400, #milliseconds + frame_transition_duration=600, #milliseconds + debug_mode=False + ): + + if debug_mode: + start_time_function = time.perf_counter() + print(f'Animation function called at {time.strftime("%H:%M:%S", time.localtime())}') + + full_patient_df = reshape_for_animations(event_log, + every_x_time_units=every_x_time_units, + limit_duration=limit_duration, + step_snapshot_max=step_snapshot_max, + debug_mode=debug_mode) + + if debug_mode: + print(f'Reshaped animation dataframe finished construction at {time.strftime("%H:%M:%S", time.localtime())}') + -def animate_queue_activity_bar_chart(minute_counts_df_complete, - event_order, - rep=1): - # Downsample to only include a snapshot every 10 minutes (else it falls over completely) - # For runs of more days will have to downsample more aggressively - every 10 minutes works for 15 days - fig = px.bar(minute_counts_df_complete[minute_counts_df_complete["rep"] == int(rep)].sort_values('minute'), - x="event", - y="value", - animation_frame="minute", - range_y=[0,minute_counts_df_complete['value'].max()*1.1]) - fig.update_xaxes(categoryorder='array', - categoryarray= event_order) + full_patient_df_plus_pos = generate_animation_df( + full_patient_df=full_patient_df, + event_position_df=event_position_df, + wrap_queues_at=wrap_queues_at, + step_snapshot_max=step_snapshot_max, + gap_between_entities=gap_between_entities, + gap_between_resources=gap_between_resources, + gap_between_rows=gap_between_rows, + debug_mode=debug_mode + ) + + animation = generate_animation( + full_patient_df_plus_pos=full_patient_df_plus_pos, + event_position_df=event_position_df, + scenario=scenario, + plotly_height=plotly_height, + plotly_width=plotly_width, + include_play_button=include_play_button, + add_background_image=add_background_image, + display_stage_labels=display_stage_labels, + icon_and_text_size=icon_and_text_size, + override_x_max=override_x_max, + override_y_max=override_y_max, + time_display_units=time_display_units, + setup_mode=setup_mode, + resource_opacity=resource_opacity, + custom_resource_icon=custom_resource_icon, + frame_duration=frame_duration, #milliseconds + frame_transition_duration=frame_transition_duration, #milliseconds + debug_mode=debug_mode + ) - fig["layout"].pop("updatemenus") + if debug_mode: + end_time_function = time.perf_counter() + print(f'Total Time Elapsed: {(end_time_function - start_time_function):.2f} seconds') - return fig \ No newline at end of file + return animation \ No newline at end of file diff --git "a/pages/1_\360\237\232\266\342\200\215\342\231\202\357\270\217_Simulating_Arrivals.py" "b/pages/1_\360\237\232\266\342\200\215\342\231\202\357\270\217_Simulating_Arrivals.py" index f045093..7c5baa5 100644 --- "a/pages/1_\360\237\232\266\342\200\215\342\231\202\357\270\217_Simulating_Arrivals.py" +++ "b/pages/1_\360\237\232\266\342\200\215\342\231\202\357\270\217_Simulating_Arrivals.py" @@ -1,7 +1,9 @@ ''' -A Streamlit application based on Monks and +A Streamlit application based on the open treatment centre simulation model from Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) -Allows users to interact with an increasingly more complex treatment simulation +Original Model: https://github.com/TomMonks/treatment-centre-sim/tree/main + +Allows users to interact with an increasingly complex treatment simulation ''' import time import asyncio @@ -40,7 +42,7 @@ gc.collect() -tab1, tab2, tab3 = st.tabs(["Playground", "Exercise", "Information"]) +tab3, tab2, tab1 = st.tabs(["Information", "Exercise", "Playground"]) with tab3: @@ -52,20 +54,20 @@ %%{ init: { 'theme': 'base', 'themeVariables': {'lineColor': '#b4b4b4'} } }%% flowchart LR A[Arrival] --> B{Trauma or non-trauma} - B --> B1{Trauma Pathway} + B --> B1{Trauma Pathway} B --> B2{Non-Trauma Pathway} - + B1 --> C[Stabilisation] C --> E[Treatment] E ----> F - + B2 --> D[Registration] D --> G[Examination] G --> H[Treat?] H ----> F[Discharge] H --> I[Non-Trauma Treatment] - I --> F + I --> F C -.-> Z([Trauma Room]) Z -.-> C @@ -89,52 +91,52 @@ class A highlight; class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; """ ) - + st.markdown( """ - To start with, we need to create some simulated patients who will turn up to our centre. + To start with, we need to create some simulated patients who will turn up to our centre. - To simulate patient arrivals, we will use the exponential distribution, which looks a bit like this. + To simulate patient arrivals, we will use the exponential distribution, which looks a bit like this. """ ) - + exp_dist = Exponential(mean=5) - exp_fig_example = px.histogram(exp_dist.sample(size=5000), + exp_fig_example = px.histogram(exp_dist.sample(size=5000), width=600, height=300) - exp_fig_example.layout.update(showlegend=False, + exp_fig_example.layout.update(showlegend=False, margin=dict(l=0, r=0, t=0, b=0)) st.plotly_chart(exp_fig_example, use_container_width=True) st.markdown( """ -To start with, we're just going to assume people arrive at a consistent rate throughout all 24 hours of the day. This isn't very realistic, but we can refine this later. +To start with, we're just going to assume people arrive at a consistent rate throughout all 24 hours of the day. This isn't very realistic, but we can refine this later. -When a patient arrives, the computer will pick a random number from this distribution to decide how long it will be before the next patient arrives at our treatment centre. +When a patient arrives, the computer will pick a random number from this distribution to decide how long it will be before the next patient arrives at our treatment centre. -Where the bar is very high, there is a high chance that the random number picked will be somewhere around that value. +Where the bar is very high, there is a high chance that the random number picked will be somewhere around that value. -Where the bar is very low, it's very unlikely that the number picked will be from around that area - but it's not impossible. +Where the bar is very low, it's very unlikely that the number picked will be from around that area - but it's not impossible. -So what this ends up meaning is that, in this case, it's quite likely that the gap between each patient turning up at our centre will be somewhere between 0 and 10 minutes - and in fact, most of the time, someone will turn up every 2 or 3 minutes. However, now and again, we'll get a quiet period - and it might be 20 or 30 minutes until the next person arrives. +So what this ends up meaning is that, in this case, it's quite likely that the gap between each patient turning up at our centre will be somewhere between 0 and 10 minutes - and in fact, most of the time, someone will turn up every 2 or 3 minutes. However, now and again, we'll get a quiet period - and it might be 20 or 30 minutes until the next person arrives. -This is quite realistic for a lot of systems - people tend to arrive fairly regularly, but sometimes the gap will be longer. +This is quite realistic for a lot of systems - people tend to arrive fairly regularly, but sometimes the gap will be longer. -As we get into more complex models, we can vary the distribution for different times of day or different months of the year so we can reflect real-world patterns better, but for now, we're just going to assume the arrival pattern is consistent. +As we get into more complex models, we can vary the distribution for different times of day or different months of the year so we can reflect real-world patterns better, but for now, we're just going to assume the arrival pattern is consistent. # Variability and Computers Without getting too philosophical, the version of reality that happens is just one possible version! -Maybe we're going to get a really hot summer that means our department is busier due to heatstroke and people having accidents outside. -Maybe it will rain all summer and everyone will stay indoors. +Maybe we're going to get a really hot summer that means our department is busier due to heatstroke and people having accidents outside. +Maybe it will rain all summer and everyone will stay indoors. -And what if lots of people turn up really close together? How well does our department cope with that? +And what if lots of people turn up really close together? How well does our department cope with that? -So instead of just generating one set of arrivals, we will run the simulation multiple times. +So instead of just generating one set of arrivals, we will run the simulation multiple times. The first time the picks might be like this: @@ -144,15 +146,15 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; **4 minute gap, 25 minute gap, 2 minute gap, 1 minute gap** -And so on. +And so on. ## Random seeds -Because computers aren't very good at being truly random, we give them a little nudge by telling them a 'random seed' to start from. +Because computers aren't very good at being truly random, we give them a little nudge by telling them a 'random seed' to start from. -You don't need to worry about how that works - but if our random seed is 1, we will draw a different set of times from our distribution to if our random seed is 100. +You don't need to worry about how that works - but if our random seed is 1, we will draw a different set of times from our distribution to if our random seed is 100. -This allows us to make lots of different realities! +This allows us to make lots of different realities! """ ) @@ -163,27 +165,27 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; st.markdown( """ - - Try changing the slider with the title *'How many patients should arrive per day on average?'*. - - Look at the graph below it. The horizontal axis (the bottom one) shows the number of minutes - + - Try changing the slider with the title *'How many patients should arrive per day on average?'*. + + Look at the graph below it. The horizontal axis (the bottom one) shows the number of minutes + How does the shape of the graph change when you change the value? --- - - Change the slider *'How many patients should arrive per day on average?'* back to the default (80) and click on 'Run simulation'. - - Look at the charts that show the variation in patient arrivals per simulation run. - - Look at the scatter (dot) plots at the bottom of the page to understand how the arrival times of patients varies across different simulation runs and different days. - - Hover over the dots to see more detail about the arrival time of each patient. By 6am, roughly how many patients have arrived in each simulation run? + - Change the slider *'How many patients should arrive per day on average?'* back to the default (80) and click on 'Run simulation'. + - Look at the charts that show the variation in patient arrivals per simulation run. + - Look at the scatter (dot) plots at the bottom of the page to understand how the arrival times of patients varies across different simulation runs and different days. + - Hover over the dots to see more detail about the arrival time of each patient. By 6am, roughly how many patients have arrived in each simulation run? - Think about how this randomness in arrival times across different runs could be useful. --- - Try changing the random number the computer uses without changing anything else. What happens to the number of patients? Do the bar charts and histograms look different? - + """) - + with st.expander("Click here for bonus exercises"): st.markdown( """ - --- - - Try running the simulation for under 5 days. What happens to the height of the bars in the first bar chart compared to running the simulation for more days? Are the bars larger or smaller? + --- + - Try running the simulation for under 5 days. What happens to the height of the bars in the first bar chart compared to running the simulation for more days? Are the bars larger or smaller? --- - Try increasing the number of simulation runs. What do you notice about the *shape* of the histograms? Where are the bars highest? """ @@ -200,31 +202,31 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; run_time_days = st.slider("🗓️ How many days should we run the simulation for each time?", 1, 31, step=1, value=15) - + n_reps = st.slider("🔁 How many times should the simulation run?", 1, 25, step=1, value=10) - - + + with col1_2: mean_arrivals_per_day = st.slider("🧍 How many patients should arrive per day on average?", 60, 300, step=5, value=80) - + st.markdown("The graph below shows the distribution of time between arrivals for a sample of 2500 patients.") # Will need to convert mean arrivals per day into interarrival time and share that exp_dist = Exponential(mean=60/(mean_arrivals_per_day/24), random_seed=seed) - exp_fig = px.histogram(exp_dist.sample(size=2500), + exp_fig = px.histogram(exp_dist.sample(size=2500), width=500, height=250, labels={ "value": "Time between patients arriving (Minutes)" }) - + exp_fig.update_layout(yaxis_title="") - exp_fig.layout.update(showlegend=False, + exp_fig.layout.update(showlegend=False, margin=dict(l=0, r=0, t=0, b=0)) exp_fig.update_xaxes(tick0=0, dtick=10, range=[0, 260]) @@ -234,7 +236,7 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; # set number of replication - args = Scenario(random_number_set=seed, + args = Scenario(random_number_set=seed, # We want to pass the interarrival time here # To get from daily arrivals to average interarrival time, # divide the number of arrivals by 24 to get arrivals per hour, @@ -266,7 +268,7 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; ) - patient_log = pd.concat([detailed_outputs[i]['results']['full_event_log'].assign(Rep= i+1) + patient_log = pd.concat([detailed_outputs[i]['results']['full_event_log'].assign(Rep= i+1) for i in range(n_reps)]) results = pd.concat([detailed_outputs[i]['results']['summary_df'].assign(rep= i+1) @@ -276,7 +278,7 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; patient_log = patient_log.assign(model_day = (patient_log.time/24/60).pipe(np.floor)+1) patient_log = patient_log.assign(time_in_day= (patient_log.time - ((patient_log.model_day -1) * 24 * 60)).pipe(np.floor)) # patient_log = patient_log.assign(time_in_day_ (patient_log.time_in_day/60).pipe(np.floor)) - patient_log['patient_full_id'] = patient_log['Rep'].astype(str) + '_' + patient_log['patient'].astype(str) + patient_log['patient_full_id'] = patient_log['Rep'].astype(str) + '_' + patient_log['patient'].astype(str) patient_log['rank'] = patient_log['time_in_day'].rank(method='max') #st.success('Done!') @@ -287,13 +289,13 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; st.markdown( """ - The graph below shows the variation in the number of patients generated per day (on average) in a single simulation run. + The graph below shows the variation in the number of patients generated per day (on average) in a single simulation run. This can help us understand how much variation we get between model runs when we don't change parameters, only the random seed. - - The height of each bar is relative to the **first** simulation run. + + The height of each bar is relative to the **first** simulation run. A bar that is **positive** shows that **more** patients were generated on average per day in that simulation than in the first simulation. - + A bar that is **negative** shows that **fewer* patients were generated on average per day in that simulation than in the first simulation. """ ) @@ -301,36 +303,36 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; #progress_bar = st.progress(0) # This all used to work nicely when running in standard streamlit, but in stlite the animated element no longer works - # So it's all a bit redundant and could be nicely simplified, but leaving for now as it works + # So it's all a bit redundant and could be nicely simplified, but leaving for now as it works # chart_mean_daily = st.bar_chart(results[['00_arrivals']].iloc[[0]]/run_time_days) # chart_mean_daily = st.bar_chart( # results[['00_arrivals']].iloc[[0]] / run_time_days - results[['00_arrivals']].iloc[[0]] / run_time_days, # height=250 - + # ) - # chart_total = st.bar_chart(results[['00_arrivals']].iloc[[0]]) + # chart_total = st.bar_chart(results[['00_arrivals']].iloc[[0]]) - results['00a_arrivals_difference'] = ((results[['00_arrivals']].iloc[0]['00_arrivals'].astype(int))/run_time_days) - (results['00_arrivals']/run_time_days) + results['00a_arrivals_difference'] = ((results[['00_arrivals']].iloc[0]['00_arrivals'].astype(int))/run_time_days) - (results['00_arrivals']/run_time_days) results["colour_00a"] = np.where(results['00a_arrivals_difference']<0, 'neg', 'pos') # st.write(results) - run_diff_bar_fig = px.bar(results.reset_index(drop=False), + run_diff_bar_fig = px.bar(results.reset_index(drop=False), x="rep", y='00a_arrivals_difference', color="colour_00a") run_diff_bar_fig.update_layout( yaxis_title="Difference in daily patients between first run and this run", xaxis_title="Simulation Run") - - + + run_diff_bar_fig.layout.update(showlegend=False) run_diff_bar_fig.update_xaxes(tick0=1, dtick=1) st.plotly_chart( - run_diff_bar_fig, + run_diff_bar_fig, use_container_width=True ) @@ -365,7 +367,7 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; i+2, new_rows.iloc[0]['00_arrivals'].astype(int), (new_rows.iloc[0]['00_arrivals']/run_time_days).round(1) - ) + "\n" + status_text_string + ) + "\n" + status_text_string # Update status text. status_text.text(status_text_string) @@ -378,10 +380,10 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; st.markdown( """ - This plot shows the variation in the **total** daily patients per run. - + This plot shows the variation in the **total** daily patients per run. + The horizontal axis shows the range of patients generated within a simulation run. - + The height of the bar shows how many simulation runs had an output in that group. """ ) @@ -399,7 +401,7 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; total_fig, use_container_width=True ) - + with col_a_2: st.subheader( "Histogram: Average Daily Patients per Run" @@ -407,10 +409,10 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; st.markdown( """ - This plot shows the variation in the **average** daily patients per run. - + This plot shows the variation in the **average** daily patients per run. + The horizontal axis shows the range of patients generated within a simulation run - + The height of the bar shows how many simulation runs had an output in that group. """ ) @@ -420,7 +422,7 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; nbins=5 ) daily_average_fig.layout.update(showlegend=False) - + daily_average_fig.update_layout(yaxis_title="Number of Simulation Runs", xaxis_title="Average Daily Patients Generated in Run") @@ -429,8 +431,8 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; use_container_width=True ) - - + + #facet_col_wrap_calculated = np.ceil(run_time_days/4).astype(int) @@ -447,31 +449,31 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; st.markdown( """ The plots below show the minute-by-minute arrivals of patients across different model replications and different days. - Only the first 10 replications and the first 5 days of the model are shown. + Only the first 10 replications and the first 5 days of the model are shown. + + Each dot is a single patient arriving. - Each dot is a single patient arriving. + From left to right within each plot, we start at one minute past midnight and move through the day until midnight. - From left to right within each plot, we start at one minute past midnight and move through the day until midnight. + Looking from the top to the bottom of each plot, we have the model replications. - Looking from the top to the bottom of each plot, we have the model replications. - - Each horizontal line of dots represents a **full day** for one model replication. + Each horizontal line of dots represents a **full day** for one model replication. Hovering over the dots will show the exact time that each patient arrived during that model replication and the number of patients that have arrived at that point in the simulation run. - """ + """ ) for i in range(5): st.markdown("### Day {}".format(i+1)) - minimal_log = patient_log[(patient_log['event'] == 'arrival') & - (patient_log['Rep'] <= 10) & + minimal_log = patient_log[(patient_log['event'] == 'arrival') & + (patient_log['Rep'] <= 10) & (patient_log['model_day'] == i+1)] - + minimal_log['Rep_str'] = minimal_log['Rep'].astype(str) time_plot = px.scatter( minimal_log.sort_values("minute"), - x="minute", + x="minute", y="Rep", color="Rep_str", custom_data=["Rep", "minute_in_day", "patient"], @@ -481,12 +483,12 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; height=300, opacity=0.5 ) - + del minimal_log time_plot.update_traces( hovertemplate="
".join([ - "Replication:%{customdata[0]}", + "Replication:%{customdata[0]}", "Time of patient arrival: %{customdata[1]}", "Arrival in this simulation run: %{customdata[2]}" ]) @@ -499,50 +501,50 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; tick0 = 1, dtick = 1 )) - - time_plot.layout.update(showlegend=False, + + time_plot.layout.update(showlegend=False, margin=dict(l=0, r=0, t=0, b=0)) - + st.plotly_chart(time_plot, use_container_width=True) - + del time_plot gc.collect() - + with tab2a: st.markdown( """ The plots below show the minute-by-minute arrivals of patients across different model replications and different days. - Only the first 10 days and the first 5 replications of the model are shown. + Only the first 10 days and the first 5 replications of the model are shown. - Each dot is a single patient arriving. + Each dot is a single patient arriving. - From left to right within each plot, we start at one minute past midnight and move through the day until midnight. + From left to right within each plot, we start at one minute past midnight and move through the day until midnight. - Looking from the top to the bottom of each plot, we have the days within a single model run. - - Each horizontal line of dots represents one **full day**. + Looking from the top to the bottom of each plot, we have the days within a single model run. + + Each horizontal line of dots represents one **full day**. Hovering over the dots will show the exact time that each patient arrived and how many patients have arrived at that point in time. - """ + """ ) for i in range(5): st.markdown("### Model Replication {}".format(i+1)) - minimal_log = patient_log[(patient_log['event'] == 'arrival') & - (patient_log['Rep'] == i+1) & + minimal_log = patient_log[(patient_log['event'] == 'arrival') & + (patient_log['Rep'] == i+1) & (patient_log['model_day'] <=10)].sort_values("minute_in_day") - + minimal_log['model_day_str'] = minimal_log['model_day'].astype(str) minimal_log['minute'] = minimal_log.apply(lambda x: x['minute'] - pd.Timedelta(x['model_day'], unit="days"), axis=1) - + minimal_log['arrival_in_day'] = minimal_log.sort_values("minute").groupby('model_day')["minute"].rank() time_plot = px.scatter( minimal_log.sort_values("minute"), - x="minute", + x="minute", y="model_day_str", color="model_day_str", custom_data=["model_day", "minute_in_day", "arrival_in_day"], @@ -552,7 +554,7 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; height=300, opacity=0.5 ) - + del minimal_log time_plot.update_traces( @@ -570,12 +572,12 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; tick0 = 1, dtick = 1 )) - - time_plot.layout.update(showlegend=False, + + time_plot.layout.update(showlegend=False, margin=dict(l=0, r=0, t=0, b=0)) - + st.plotly_chart(time_plot, use_container_width=True) - + del time_plot gc.collect() @@ -584,14 +586,14 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; """ The plot below shows the cumulative number of patients arriving over time for the first 5 days of each simulation run. - Each line represents one model replication. + Each line represents one model replication. - Moving from left to right, we have the first 5 days of the model runs. + Moving from left to right, we have the first 5 days of the model runs. - Clicking on a model replication in the legend on the right-hand side of the graph will hide that line. - Clicking on it again will make it reappear. + Clicking on a model replication in the legend on the right-hand side of the graph will hide that line. + Clicking on it again will make it reappear. - By comparing the height of the two lines, you can see how similar or different the total number of patients generated are at any given point in time. + By comparing the height of the two lines, you can see how similar or different the total number of patients generated are at any given point in time. @@ -599,39 +601,39 @@ class B,B1,B2,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z unlight; """ ) - minimal_log = patient_log[(patient_log['event'] == 'arrival') & - (patient_log['Rep'] <=10) & + minimal_log = patient_log[(patient_log['event'] == 'arrival') & + (patient_log['Rep'] <=10) & (patient_log['model_day'] <=5)].sort_values("minute") minimal_log['cumulative_count'] = minimal_log.groupby('Rep').cumcount() minimal_log['Rep_str'] = minimal_log['Rep'].astype(str) cumulative_arrivals_fig = px.line( - minimal_log, - x="minute", - y="cumulative_count", + minimal_log, + x="minute", + y="cumulative_count", color="Rep_str", category_orders={'Rep_str': [str(i+1) for i in range(minimal_log['Rep'].max())]}, height=800) - + hovertemplate = '%{x}: %{y} arrivals' # customdata = list(minimal_log[["Rep_str"]].to_numpy()) cumulative_arrivals_fig.update_traces( # customdata=customdata, hovertemplate=hovertemplate - + ) # del customdata gc.collect() - + cumulative_arrivals_fig.update_layout(xaxis_title="Model Day", yaxis_title="Cumulative Arrivals", legend_title_text='Model Replication', hovermode="x unified") - st.plotly_chart(cumulative_arrivals_fig, + st.plotly_chart(cumulative_arrivals_fig, use_container_width=True) - + del cumulative_arrivals_fig - gc.collect() \ No newline at end of file + gc.collect() diff --git "a/pages/2_\360\237\233\217\357\270\217_Using_A_Simple_Resource.py" "b/pages/2_\360\237\233\217\357\270\217_Using_A_Simple_Resource.py" index 09a785b..5fbae85 100644 --- "a/pages/2_\360\237\233\217\357\270\217_Using_A_Simple_Resource.py" +++ "b/pages/2_\360\237\233\217\357\270\217_Using_A_Simple_Resource.py" @@ -1,7 +1,9 @@ ''' -A Streamlit application based on Monks and +A Streamlit application based on the open treatment centre simulation model from Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) -Allows users to interact with an increasingly more complex treatment simulation +Original Model: https://github.com/TomMonks/treatment-centre-sim/tree/main + +Allows users to interact with an increasingly complex treatment simulation ''' import asyncio import gc @@ -13,8 +15,7 @@ from helper_functions import add_logo, mermaid, center_running from model_classes import Scenario, multiple_replications from distribution_classes import Normal -from output_animation_functions import reshape_for_animations, animate_activity_log - +from output_animation_functions import reshape_for_animations, generate_animation_df, generate_animation # Set page parameters st.set_page_config( page_title="Using a Simple Resource", @@ -37,7 +38,7 @@ gc.collect() # tab1, tab2, tab3 = st.tabs(["Introduction", "Exercise", "Playground"]) -tab1, tab2, tab3 = st.tabs(["Playground", "Exercise", "Information"]) +tab3, tab2, tab1 = st.tabs(["Information", "Exercise", "Playground"]) with tab3: @@ -47,7 +48,7 @@ We need to add our first resource. - Resources exist inside our simulation, and can be nurses, rooms, ambulances - whatever we need them to be. + Resources exist inside our simulation, and can be nurses, rooms, ambulances - whatever we need them to be. When someone reaches the front of the queue, they will be allocated to a resource that is currently free. They will hold onto this resource for as long as they need it, and then they will let go of it and move on to the next part of the process. @@ -57,7 +58,7 @@ So for now, let's make it so that when someone arrives, they need to be treated, and to do this they will need a resource. For now, we're keeping it simple - let's assume each nurse has a room that they treat people in. They always stay in this room, and as soon as a patient has finished being treated, the patient will leave and the nurse (and room) will become available again. - This means we just have one type of resource to worry about. + This means we just have one type of resource to worry about. """ ) @@ -78,19 +79,19 @@ st.markdown( """ - For now, we'll assume all of our patients are roughly equally injured - but there might still be some variation in how long it takes to treat them. Some might need a few stitches, some might just need a quick bit of advice. + For now, we'll assume all of our patients are roughly equally injured - but there might still be some variation in how long it takes to treat them. Some might need a few stitches, some might just need a quick bit of advice. - This time, we're going to sample from a different distribution - the normal distribution. A few people won't take very long to fix up, while a few might take quite a long time - but most of the people will take an amount of time that's somewhere in the middle. + This time, we're going to sample from a different distribution - the normal distribution. A few people won't take very long to fix up, while a few might take quite a long time - but most of the people will take an amount of time that's somewhere in the middle. """) - + norm_dist_example = Normal(mean=50, sigma=10) - norm_fig_example = px.histogram(norm_dist_example.sample(size=5000), + norm_fig_example = px.histogram(norm_dist_example.sample(size=5000), height=300) norm_fig_example.update_layout(yaxis_title="", xaxis_title="Consultation Time
(Minutes)") - norm_fig_example.layout.update(showlegend=False, + norm_fig_example.layout.update(showlegend=False, margin=dict(l=0, r=0, t=0, b=0)) st.plotly_chart(norm_fig_example, use_container_width=True) @@ -102,27 +103,27 @@ """) - + with tab2: st.markdown( """ ### Things to Try Out - - Try changing the sliders for consultation time and variation in consultation time. - What happens to the shape of the graph below the sliders? + - Try changing the sliders for consultation time and variation in consultation time. + What happens to the shape of the graph below the sliders? --- - Put the consulation times back to the default (50 minutes length on average, 10 minutes of variation). Run the model and take a look at the animated flow of patients through the system. What do you notice about - the number of nurses in use? Do they ever get any breaks? - the size of the queue for treatment at different times - does it get bigger and smaller at different times, or just keep growing? --- - - What happens when you play around with the number of nurses we have available? - - Look at the queues, but look at the resource utilisation too. The resource utilisation tells us how much of the time each nurse is busy rather than waiting for a patient to turn up. + - What happens when you play around with the number of nurses we have available? + - Look at the queues, but look at the resource utilisation too. The resource utilisation tells us how much of the time each nurse is busy rather than waiting for a patient to turn up. - Can you find a middle ground where the nurse is being used a good amount without the queues building up? --- """) - + with st.expander("Click here for bonus exercises"): st.markdown( """ @@ -148,16 +149,16 @@ with st.expander("Previous Parameters"): st.markdown("If you like, you can edit these parameters too!") - + n_reps = st.slider("🔁 How many times should the simulation run?", 1, 30, step=1, value=6) - + run_time_days = st.slider("🗓️ How many days should we run the simulation for each time?", 1, 40, step=1, value=10) - + mean_arrivals_per_day = st.slider("🧍 How many patients should arrive per day on average?", 10, 300, step=5, value=120) @@ -172,30 +173,30 @@ norm_dist = Normal(consult_time, consult_time_sd, random_seed=seed) norm_fig = px.histogram(norm_dist.sample(size=2500), height=150) - + norm_fig.update_layout(yaxis_title="", xaxis_title="Consultation Time
(Minutes)") - norm_fig.update_xaxes(tick0=0, dtick=10, range=[0, + norm_fig.update_xaxes(tick0=0, dtick=10, range=[0, # max(norm_dist.sample(size=2500)) 240 ]) - - norm_fig.layout.update(showlegend=False, + + norm_fig.layout.update(showlegend=False, margin=dict(l=0, r=0, t=0, b=0)) - + st.markdown("#### Consultation Time Distribution") st.plotly_chart(norm_fig, use_container_width=True, config = {'displayModeBar': False}) - - - + + + # A user must press a streamlit button to run the model button_run_pressed = st.button("Run simulation") - - + + if button_run_pressed: # add a spinner and then display success box @@ -220,31 +221,32 @@ results = pd.concat([detailed_outputs[i]['results']['summary_df'].assign(rep= i+1) for i in range(n_reps)]).set_index('rep') - + full_event_log = pd.concat([detailed_outputs[i]['results']['full_event_log'].assign(rep= i+1) for i in range(n_reps)]) - + del detailed_outputs gc.collect() animation_dfs_log = reshape_for_animations( - full_event_log=full_event_log[ + event_log=full_event_log[ (full_event_log['rep']==1) & - ((full_event_log['event_type']=='queue') | (full_event_log['event_type']=='resource_use') | (full_event_log['event_type']=='arrival_departure')) & + ((full_event_log['event_type']=='queue') | (full_event_log['event_type']=='resource_use') | (full_event_log['event_type']=='arrival_departure')) # Limit to first 5 days - (full_event_log['time'] <= 60*24*5) ], - every_x_minutes=5 - )['full_patient_df'] - + every_x_time_units=5, + step_snapshot_max=45, + limit_duration=60*24*5 + ) + del full_event_log gc.collect() if button_run_pressed: tab1, tab2, tab3 = st.tabs( ["Animated Log", "Simple Graphs", "Advanced Graphs"] - ) - + ) + # st.markdown(""" # You can click on the three tabs below ("Animated Log", "Simple Graphs", and "Advanced Graphs") to view different outputs from the model. # """) @@ -257,52 +259,59 @@ event_position_df = pd.DataFrame([ {'event': 'arrival', 'x': 50, 'y': 300, 'label': "Arrival" }, - - # Triage - minor and trauma - {'event': 'treatment_wait_begins', 'x': 190, 'y': 170, 'label': "Waiting for Treatment" }, - {'event': 'treatment_begins', 'x': 190, 'y': 110, 'resource':'n_cubicles_1', 'label': "Being Treated" }, - {'event': 'exit', + # Triage - minor and trauma + {'event': 'treatment_wait_begins', 'x': 200, 'y': 170, 'label': "Waiting for Treatment" }, + {'event': 'treatment_begins', 'x': 200, 'y': 110, 'resource':'n_cubicles_1', 'label': "Being Treated" }, + + {'event': 'exit', 'x': 270, 'y': 70, 'label': "Exit"} - + ]) st.markdown( """ - The plot below shows a snapshot every 5 minutes of the position of everyone in our emergency department model. - - The buttons to the left of the slider below the plot can be used to start and stop the animation. + The plot below shows a snapshot every 5 minutes of the position of everyone in our emergency department model. + + The buttons to the left of the slider below the plot can be used to start and stop the animation. - Clicking on the bar below the plot and dragging your cursor to the left or right allows you to rapidly jump through to a different time in the simulation. + Clicking on the bar below the plot and dragging your cursor to the left or right allows you to rapidly jump through to a different time in the simulation. - Only the first replication of the simulation is shown. + Only the first replication of the simulation is shown. """ ) - st.plotly_chart(animate_activity_log( - full_patient_df=animation_dfs_log[animation_dfs_log["minute"]<=60*24*5], + full_patient_df_plus_pos = generate_animation_df( + full_patient_df=animation_dfs_log, + event_position_df = event_position_df, + wrap_queues_at=15, + gap_between_entities=10, + gap_between_rows=20, + step_snapshot_max=45 + ) + + st.plotly_chart(generate_animation( + full_patient_df_plus_pos=full_patient_df_plus_pos, event_position_df = event_position_df, scenario=args, include_play_button=True, - return_df_only=False, plotly_height=700, plotly_width=1200, override_x_max=300, override_y_max=500, - wrap_queues_at=10, time_display_units="dhm", display_stage_labels=False, add_background_image="https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/resources/Simplest%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png", ), use_container_width=False, config = {'displayModeBar': False}) - + del animation_dfs_log gc.collect() with tab2: in_range_util = sum((results.mean().filter(like="util")<0.85) & (results.mean().filter(like="util") > 0.65)) - in_range_wait = sum((results.mean().filter(like="wait")<120)) + in_range_wait = sum((results.mean().filter(like="wait")<120)) col_res_a, col_res_b = st.columns([1,1]) @@ -312,11 +321,11 @@ #util_fig_simple = px.bar(results.mean().filter(like="util"), opacity=0.5) st.markdown( """ - The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. - The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. - - If utilisation is below this, you might want to **reduce** the number of those resources available. - + The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. + The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. + + If utilisation is below this, you might want to **reduce** the number of those resources available. + If utilisation is above this point, you may want to **increase** the number of that type of resource available. """ ) @@ -342,11 +351,16 @@ range=[-0.05, 1.1]) # util_fig_simple.data = util_fig_simple.data[::-1] util_fig_simple.update_xaxes(labelalias={ - "01b_treatment_util": "Treatment Bays", + "01b_treatment_util": "Treatment Bays", }, tickangle=0) - - util_fig_simple.update_layout(margin=dict(l=0, r=0, t=0, b=0), - yaxis_tickformat = '.0%') + + util_fig_simple.update_layout( + margin=dict(l=0, r=0, t=0, b=0), + yaxis_tickformat = '.0%', + title=dict(text="Utilisation of Resources - Average Across Simulation Runs", + automargin=True, + yref='paper') + ) st.plotly_chart( util_fig_simple, @@ -354,7 +368,7 @@ config = {'displayModeBar': False} ) - + with col_res_b: #util_fig_simple = px.bar(results.mean().filter(like="wait"), opacity=0.5) st.metric(label=":clock2: **Wait Metrics in Ideal Range**", value="{} of {}".format(in_range_wait, len(results.mean().filter(like="01b")))) @@ -363,15 +377,15 @@ """ The emergency department wants to ensure people wait no longer than 2 hours (120 minutes) to be seen. This needs to be balanced with the utilisation graphs on the left. - + The green box shows waits of less than two hours. If the bars fall within this range, the number of resources does not need to be changed. """ ) wait_fig_simple = go.Figure() - wait_fig_simple.add_hrect(y0=0, y1=60*2, fillcolor="#5DFDA0", + wait_fig_simple.add_hrect(y0=0, y1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) - + wait_fig_simple.add_bar(x=results.mean().filter(like="01a").index.tolist(), y=results.mean().filter(like="01a").tolist()) @@ -381,7 +395,12 @@ # wait_fig_simple.data = wait_fig_simple.data[::-1] wait_fig_simple.update_yaxes(title_text='Wait for Treatment Stage (Minutes)') - wait_fig_simple.update_layout(margin=dict(l=0, r=0, t=0, b=0)) + wait_fig_simple.update_layout( + margin=dict(l=0, r=0, t=0, b=0), + title=dict(text="Waits at Each Step - Average Across Simulation Runs", + automargin=True, + yref='paper') + ) st.plotly_chart( wait_fig_simple, @@ -399,26 +418,30 @@ """ The emergency department wants to ensure people wait no longer than 2 hours (120 minutes) to be seen. This needs to be balanced with the utilisation graphs on the left. - + The green box shows waits of less than two hours. If the bars fall within this range, the number of resources does not need to be changed. """ ) wait_target_simple = go.Figure() - wait_target_simple.add_hrect(y0=0.85, y1=1, fillcolor="#5DFDA0", + wait_target_simple.add_hrect(y0=0.85, y1=1, fillcolor="#5DFDA0", opacity=0.3, line_width=0) - + wait_target_simple.add_bar(x=results.median().filter(like="01c").index.tolist(), y=results.median().filter(like="01c").tolist()) wait_target_simple.update_xaxes(labelalias={ - "01c_treatment_wait_target_met": "Treatment Wait - Target Met" + "01c_treatment_wait_target_met": "Treatment Wait - Target Met" }, tickangle=0) # wait_fig_simple.data = wait_fig_simple.data[::-1] wait_target_simple.update_yaxes(title_text='Average % of patients where 2 hour wait target met') - wait_target_simple.update_layout(margin=dict(l=0, r=0, t=0, b=0), - yaxis_tickformat = '.0%') + wait_target_simple.update_layout(margin=dict(l=0, r=0, t=0, b=0), + yaxis_tickformat = '.0%', + title=dict(text="% of Patients Waiting Less than Target Time - Average Across Simulation Runs", + automargin=True, + yref='paper') + ) st.plotly_chart( wait_target_simple, @@ -432,9 +455,9 @@ st.markdown( """ - We can use **box plots** to help us understand the variation in each result during a model run. - - Because of the variation in the patterns of arrivals, as well as the variation in the length of consultations, we may find that sometimes model runs fall within our desired ranges but other times, despite the parameters being the same, they don't. + We can use **box plots** to help us understand the variation in each result during a model run. + + Because of the variation in the patterns of arrivals, as well as the variation in the length of consultations, we may find that sometimes model runs fall within our desired ranges but other times, despite the parameters being the same, they don't. This gives us a better idea of how likely a redesigned system is to meet the targets. """ @@ -444,15 +467,15 @@ ### Utilisation """) util_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="util", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="util", axis=0).reset_index(), + y="variable", x="value", points="all", range_x=[0, 1.1], height=200) - - util_box.update_layout(yaxis_title="", - xaxis_title="Average Utilisation in Model Run", + + util_box.update_layout(yaxis_title="", + xaxis_title="Average Utilisation in Model Run", xaxis_tickformat = '.0%') util_box.add_vrect(x0=0.65, x1=0.85, @@ -476,27 +499,27 @@ st.plotly_chart(util_box, use_container_width=True ) - + st.markdown(""" ### Waits """) wait_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="01a", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="01a", axis=0).reset_index(), + y="variable", x="value", points="all", height=200, range_x=[0, results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="01a", axis=0).reset_index().max().value] ) - wait_box.update_layout(yaxis_title="", + wait_box.update_layout(yaxis_title="", xaxis_title="Average Wait in Model Run") wait_box.update_yaxes(labelalias={ "01a_treatment_wait": "Treatment Wait" }, tickangle=0) - wait_box.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", + wait_box.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) st.plotly_chart(wait_box, @@ -509,16 +532,16 @@ """) wait_target_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="1c", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="1c", axis=0).reset_index(), + y="variable", x="value", points="all", height=200, range_x=[0, 1.1] ) - - wait_target_box.update_layout(yaxis_title="", - xaxis_title="% of clients meeting waiting time target", + + wait_target_box.update_layout(yaxis_title="", + xaxis_title="% of clients meeting waiting time target", xaxis_tickformat = '.0%') wait_target_box.update_yaxes(labelalias={ @@ -529,28 +552,28 @@ st.plotly_chart(wait_target_box, use_container_width=True ) - - + + st.markdown(""" ### Throughput This is the percentage of clients who entered the system who had left by the time the model stopped running. Higher values are better - low values suggest a big backlog of people getting stuck in the system for a long time. - + Note that this isn't a good metric to compare across different lengths of model run, but can be useful to consider for the same length of run with different parameters. """) - + results['perc_throughput'] = results['09_throughput']/results['00_arrivals'] throughput_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="perc_throughput", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="perc_throughput", axis=0).reset_index(), + y="variable", x="value", points="all", height=200, range_x=[0, 1.1] ) - - throughput_box.update_layout(yaxis_title="", - xaxis_title="% Throughput in Model Run", + + throughput_box.update_layout(yaxis_title="", + xaxis_title="% Throughput in Model Run", xaxis_tickformat = '.0%') throughput_box.update_yaxes(labelalias={ @@ -561,7 +584,7 @@ st.plotly_chart(throughput_box, use_container_width=True ) - + del results gc.collect() diff --git "a/pages/3_\360\237\251\271_Adding_an_Optional_Step.py" "b/pages/3_\360\237\251\271_Adding_an_Optional_Step.py" index fe7c5ff..b0652fc 100644 --- "a/pages/3_\360\237\251\271_Adding_an_Optional_Step.py" +++ "b/pages/3_\360\237\251\271_Adding_an_Optional_Step.py" @@ -1,7 +1,9 @@ ''' -A Streamlit application based on Monks and +A Streamlit application based on the open treatment centre simulation model from Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) -Allows users to interact with an increasingly more complex treatment simulation +Original Model: https://github.com/TomMonks/treatment-centre-sim/tree/main + +Allows users to interact with an increasingly complex treatment simulation ''' import asyncio import gc @@ -10,7 +12,7 @@ import plotly.graph_objects as go import streamlit as st -from output_animation_functions import reshape_for_animations,animate_activity_log +from output_animation_functions import reshape_for_animations, generate_animation_df, generate_animation from helper_functions import add_logo, mermaid, center_running from model_classes import Scenario, multiple_replications @@ -34,7 +36,7 @@ gc.collect() # tab1, tab2, tab3 = st.tabs(["Introduction", "Exercise", "Playground"]) -tab1, tab2, tab3 = st.tabs(["Playground", "Exercise", "Information"]) +tab3, tab2, tab1 = st.tabs(["Information", "Exercise", "Playground"]) with tab3: @@ -42,22 +44,22 @@ Now, it's not as simple as all of our patients being looked at by a nurse and then sent on their merry way. Some of them - but not all of them - may require another step where they undergo some treatment. - + So for some people, their pathway looks like this: """) - mermaid(height=225, code= + mermaid(height=175, code= """ %%{ init: { 'flowchart': { 'curve': 'step' } } }%% %%{ init: { 'theme': 'base', 'themeVariables': {'lineColor': '#b4b4b4'} } }%% flowchart LR - + A[Arrival]----> B[Advice] - + B -.-> F([Nurse/Cubicle]) F -.-> B - + B----> C[Treatment] C -.-> G([Nurse/Cubicle]) @@ -72,17 +74,17 @@ st.markdown("But for other simpler cases, their pathway still looks like this!") - mermaid(height=225, code= + mermaid(height=175, code= """ %%{ init: { 'flowchart': { 'curve': 'step' } } }%% %%{ init: { 'theme': 'base', 'themeVariables': {'lineColor': '#b4b4b4'} } }%% flowchart LR - + A[Arrival]----> B[Advice] - + B -.-> F([Nurse/Cubicle]) F -.-> B - + B ----> Z[Discharge] classDef default font-size:18pt,font-family:lexend; @@ -94,9 +96,9 @@ """ So how do we ensure that some of our patients go down one pathway and not the other? - You guessed it - the answer is sampling from a distribution again! + You guessed it - the answer is sampling from a distribution again! - We can tell the computer the rough split we'd like to say - let's say 30% of our patients need the treatment step, but the other 70% will + We can tell the computer the rough split we'd like to say - let's say 30% of our patients need the treatment step, but the other 70% will And as before, there will be a bit of randomness, just like in the real world. In one simulation, we might end up with a 69/31 split, and the next might be 72/28, but it will always be around the expected split we've asked for. @@ -114,12 +116,12 @@ %%{ init: { 'flowchart': { 'curve': 'step' } } }%% %%{ init: { 'theme': 'base', 'themeVariables': {'lineColor': '#b4b4b4'} } }%% flowchart LR - + A[Arrival]--> B[Advice] - + B -.-> F([Nurse/Cubicle]) F -.-> B - + B----> |30% of patients| C[Treatment] C -.-> G([Nurse/Cubicle]) @@ -137,14 +139,14 @@ st.markdown( """ ### Things to Try Out - + - Run the simulation with the default values and look at the graph 'Percentage of clients requiring treatment per simulation run' on the 'Simple Graphs' tab after running the model. This shows the split between patients who do and don't require treatment. What do you notice? --- - What impact does changing the number of patients who go down this extra route (the 'probability that a patient will need treatment') have on our treatment centre's performance with the default number of nurses and doctors at each stage? --- - - Change the split of patients requiring treatment back to 0.5. + - Change the split of patients requiring treatment back to 0.5. - Can you optimize the number of nurses or doctors at each step for the different pathways to balance resource utilisation and queues? - + """ ) @@ -173,7 +175,7 @@ consult_time_sd_treat = st.slider("🕔 🕣 How much (in minutes) does the time for treatment usually vary by?", 5, 60, step=5, value=30) - + with col3: st.subheader("Pathway Probabilities") treat_p = st.slider("🤕 Probability that a patient will need treatment", 0.0, 1.0, step=0.01, value=0.5) @@ -184,26 +186,26 @@ seed = st.slider("🎲 Set a random number for the computer to start from", 1, 1000, step=1, value=42) - + n_reps = st.slider("🔁 How many times should the simulation run?", 1, 30, step=1, value=6) - + run_time_days = st.slider("🗓️ How many days should we run the simulation for each time?", 1, 40, step=1, value=10) - + mean_arrivals_per_day = st.slider("🧍 How many patients should arrive per day on average?", 10, 300, - step=5, value=140) - + step=5, value=140) + + - # A user must press a streamlit button to run the model button_run_pressed = st.button("Run simulation") - + args = Scenario( random_number_set=seed, n_exam=nurses_advice, @@ -217,7 +219,7 @@ non_trauma_treat_var=consult_time_sd_treat, non_trauma_treat_p=treat_p ) - + if button_run_pressed: # add a spinner and then display success box @@ -233,10 +235,10 @@ results = pd.concat([detailed_outputs[i]['results']['summary_df'].assign(rep= i+1) for i in range(n_reps)]).set_index('rep') - + full_event_log = pd.concat([detailed_outputs[i]['results']['full_event_log'].assign(rep= i+1) for i in range(n_reps)]) - + del detailed_outputs gc.collect() @@ -244,23 +246,23 @@ (full_event_log["event"]=="requires_treatment")][['patient','event','rep']].groupby(['rep','event']).count() animation_dfs_log = reshape_for_animations( - full_event_log=full_event_log[ + event_log=full_event_log[ (full_event_log['rep']==1) & - ((full_event_log['event_type']=='queue') | (full_event_log['event_type']=='resource_use') | (full_event_log['event_type']=='arrival_departure')) & - # Limit to first 5 days - (full_event_log['time'] <= 60*24*5) + ((full_event_log['event_type']=='queue') | (full_event_log['event_type']=='resource_use') | (full_event_log['event_type']=='arrival_departure')) ], - every_x_minutes=5 - )['full_patient_df'] - + every_x_time_units=5, + step_snapshot_max=45, + limit_duration=60*24*5 + ) + del full_event_log gc.collect() if button_run_pressed: tab1, tab2, tab3 = st.tabs( ["Animated Log", "Simple Graphs", "Advanced Graphs"] - ) - + ) + # st.markdown(""" # You can click on the three tabs below ("Animated Log", "Simple Graphs", and "Advanced Graphs") to view different outputs from the model. # """) @@ -269,31 +271,31 @@ st.markdown( """ - The plot below shows a snapshot every 5 minutes of the position of everyone in our emergency department model. - - The buttons to the left of the slider below the plot can be used to start and stop the animation. + The plot below shows a snapshot every 5 minutes of the position of everyone in our emergency department model. + + The buttons to the left of the slider below the plot can be used to start and stop the animation. - Clicking on the bar below the plot and dragging your cursor to the left or right allows you to rapidly jump through to a different time in the simulation. + Clicking on the bar below the plot and dragging your cursor to the left or right allows you to rapidly jump through to a different time in the simulation. - Only the first replication of the simulation is shown. + Only the first replication of the simulation is shown. """ ) - + event_position_df = pd.DataFrame([ {'event': 'arrival', 'x': 50, 'y': 300, 'label': "Arrival" }, # Examination - {'event': 'examination_wait_begins', 'x': 275, 'y': 360, + {'event': 'examination_wait_begins', 'x': 265, 'y': 360, 'label': "Waiting for Examination" }, - {'event': 'examination_begins', 'x': 275, 'y': 310, + {'event': 'examination_begins', 'x': 265, 'y': 310, 'resource':'n_exam', 'label': "Being Examined" }, - # Treatment (optional step) - {'event': 'treatment_wait_begins', 'x': 430, 'y': 110, + # Treatment (optional step) + {'event': 'treatment_wait_begins', 'x': 410, 'y': 110, 'label': "Waiting for Treatment" }, - {'event': 'treatment_begins', 'x': 430, 'y': 70, + {'event': 'treatment_begins', 'x': 410, 'y': 70, 'resource':'n_cubicles_1', 'label': "Being Treated" }, - {'event': 'exit', 'x': 450, 'y': 220, + {'event': 'exit', 'x': 450, 'y': 220, 'label': "Exit"}, ]) @@ -301,31 +303,38 @@ with st.spinner('Generating the animated patient log...'): # st.write(animation_dfs_log[animation_dfs_log["minute"]<=60*24*5]) - st.plotly_chart(animate_activity_log( - full_patient_df=animation_dfs_log[animation_dfs_log["minute"]<=60*24*5], + full_patient_df_plus_pos = generate_animation_df( + full_patient_df=animation_dfs_log, + event_position_df = event_position_df, + wrap_queues_at=15, + gap_between_entities=10, + gap_between_rows=20, + step_snapshot_max=45 + ) + + st.plotly_chart(generate_animation( + full_patient_df_plus_pos=full_patient_df_plus_pos, event_position_df = event_position_df, scenario=args, include_play_button=True, display_stage_labels=False, - return_df_only=False, plotly_height=700, - plotly_width=1100, + plotly_width=1000, override_x_max=500, override_y_max=400, - wrap_queues_at=20, - icon_and_text_size=18, + icon_and_text_size=20, time_display_units="dhm", add_background_image="https://raw.githubusercontent.com/hsma-programme/Teaching_DES_Concepts_Streamlit/main/resources/Branched%20Model%20Background%20Image%20-%20Horizontal%20Layout.drawio.png", ), use_container_width=False, config = {'displayModeBar': False}) - + del animation_dfs_log gc.collect() - + with tab2: in_range_util = sum((results.mean().filter(like="util")<0.85) & (results.mean().filter(like="util") > 0.65)) - in_range_wait = sum((results.mean().filter(regex="01a|02a")<120)) - in_range_wait_perc = sum((results.mean().filter(like="01c")>0.85)) + in_range_wait = sum((results.mean().filter(regex="01a|02a")<120)) + in_range_wait_perc = sum((results.mean().filter(like="01c")>0.85)) col_res_a, col_res_b = st.columns([1,1]) @@ -335,9 +344,9 @@ #util_fig_simple = px.bar(results.mean().filter(like="util"), opacity=0.5) st.markdown( """ - The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. - The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. - If utilisation is below this, you might want to **reduce** the number of those resources available. + The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. + The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. + If utilisation is below this, you might want to **reduce** the number of those resources available. If utilisation is above this point, you may want to **increase** the number of that type of resource available. """ ) @@ -358,14 +367,19 @@ util_fig_simple.add_bar(x=results.mean().filter(like="util").index.tolist(), y=results.mean().filter(like="util").tolist()) - util_fig_simple.update_layout(yaxis_tickformat = '.0%') + util_fig_simple.update_layout( + yaxis_tickformat = '.0%', + title=dict(text="Utilisation of Resources - Average Across Simulation Runs", + automargin=True, + yref='paper') + ) util_fig_simple.update_yaxes(title_text='Resource Utilisation (%)', range=[-0.05, 1.1]) # util_fig_simple.data = util_fig_simple.data[::-1] util_fig_simple.update_xaxes(labelalias={ - "01b_treatment_util": "Treatment Bays", + "01b_treatment_util": "Treatment Bays", }, tickangle=0) - + util_fig_simple.update_layout(margin=dict(l=0, r=0, t=0, b=0)) util_fig_simple.update_xaxes(labelalias={ @@ -379,7 +393,7 @@ config = {'displayModeBar': False} ) - + with col_res_b: #util_fig_simple = px.bar(results.mean().filter(like="wait"), opacity=0.5) st.metric(label=":clock2: **Wait Metrics in Ideal Range**", value="{} of {}".format(in_range_wait, len(results.mean().filter(regex="01a|02a")))) @@ -393,9 +407,9 @@ ) wait_fig_simple = go.Figure() - wait_fig_simple.add_hrect(y0=0, y1=60*2, fillcolor="#5DFDA0", + wait_fig_simple.add_hrect(y0=0, y1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) - + wait_fig_simple.add_bar(x=results.mean().filter(regex="01a|02a").index.tolist(), y=results.mean().filter(regex="01a|02a").tolist()) @@ -406,7 +420,12 @@ # wait_fig_simple.data = wait_fig_simple.data[::-1] wait_fig_simple.update_yaxes(title_text='Wait for Process Stage (Minutes)') - wait_fig_simple.update_layout(margin=dict(l=0, r=0, t=0, b=0)) + wait_fig_simple.update_layout( + margin=dict(l=0, r=0, t=0, b=0), + title=dict(text="Waits at Each Step - Average Across Simulation Runs", + automargin=True, + yref='paper') + ) st.plotly_chart( wait_fig_simple, @@ -429,19 +448,24 @@ ) wait_target_simple = go.Figure() - wait_target_simple.add_hrect(y0=0.85, y1=1, fillcolor="#5DFDA0", + wait_target_simple.add_hrect(y0=0.85, y1=1, fillcolor="#5DFDA0", opacity=0.3, line_width=0) - + wait_target_simple.add_bar(x=results.median().filter(like="01c").index.tolist(), y=results.median().filter(like="01c").tolist()) wait_target_simple.update_xaxes(labelalias={ - "01c_examination_wait_target_met": "Examination Wait - Target Met" + "01c_examination_wait_target_met": "Examination Wait - Target Met" }, tickangle=0) # wait_fig_simple.data = wait_fig_simple.data[::-1] wait_target_simple.update_yaxes(title_text='Average % of patients where 2 hour wait target met') - wait_target_simple.update_layout(margin=dict(l=0, r=0, t=0, b=0), - yaxis_tickformat = '.0%') + wait_target_simple.update_layout( + margin=dict(l=0, r=0, t=0, b=0), + yaxis_tickformat = '.0%', + title=dict(text="% of Patients Waiting Less than Target Time - Average Across Simulation Runs", + automargin=True, + yref='paper') + ) st.plotly_chart( wait_target_simple, @@ -457,7 +481,7 @@ # st.write(attribute_count_df) attribute_count_fig = px.bar( - attribute_count_df.reset_index(drop=False), + attribute_count_df.reset_index(drop=False), x="rep", y="perc", color="event") attribute_count_fig.add_hline(y=treat_p*100, line_dash="dash", line_color="#932727") @@ -482,7 +506,7 @@ attribute_count_fig, use_container_width=True ) - + del attribute_count_df gc.collect() @@ -492,9 +516,9 @@ st.markdown( """ - We can use **box plots** to help us understand the variation in each result during a model run. - - Because of the variation in the patterns of arrivals, as well as the variation in the length of consultations, we may find that sometimes model runs fall within our desired ranges but other times, despite the parameters being the same, they don't. + We can use **box plots** to help us understand the variation in each result during a model run. + + Because of the variation in the patterns of arrivals, as well as the variation in the length of consultations, we may find that sometimes model runs fall within our desired ranges but other times, despite the parameters being the same, they don't. This gives us a better idea of how likely a redesigned system is to meet the targets. """ @@ -504,14 +528,14 @@ ### Utilisation """) util_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="util", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="util", axis=0).reset_index(), + y="variable", x="value", points="all", range_x=[0, 1.1], height=200) - - util_box.update_layout(yaxis_title="", + + util_box.update_layout(yaxis_title="", xaxis_title="Average Utilisation in Model Run", xaxis_tickformat = '.0%') @@ -537,14 +561,14 @@ st.plotly_chart(util_box, use_container_width=True ) - + st.markdown(""" ### Waits """) wait_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(regex="01a|02a", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(regex="01a|02a", axis=0).reset_index(), + y="variable", x="value", points="all", height=200, @@ -557,7 +581,7 @@ "01a_examination_wait": "Examination
(Nurses)" }, tickangle=0) - wait_box.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", + wait_box.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) st.plotly_chart(wait_box, @@ -570,15 +594,15 @@ """) wait_target_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="1c", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="1c", axis=0).reset_index(), + y="variable", x="value", points="all", height=200, range_x=[0, 1.1] ) - - wait_target_box.update_layout(yaxis_title="", + + wait_target_box.update_layout(yaxis_title="", xaxis_title="% of clients meeting waiting time target", xaxis_tickformat = '.0%') @@ -590,26 +614,26 @@ st.plotly_chart(wait_target_box, use_container_width=True ) - + st.markdown(""" ### Throughput This is the percentage of clients who entered the system who had left by the time the model stopped running. Higher values are better - low values suggest a big backlog of people getting stuck in the system for a long time. - + Note that this isn't a good metric to compare across different lengths of model run, but can be useful to consider for the same length of run with different parameters. """) - + results['perc_throughput'] = results['09_throughput']/results['00_arrivals'] throughput_box = px.box( - results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="perc_throughput", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars=["rep"]).set_index('variable').filter(like="perc_throughput", axis=0).reset_index(), + y="variable", x="value", points="all", height=200, range_x=[0, 1.1] ) - - throughput_box.update_layout(yaxis_title="", + + throughput_box.update_layout(yaxis_title="", xaxis_title="Throughput in Model Run", xaxis_tickformat = '.0%') @@ -621,7 +645,7 @@ st.plotly_chart(throughput_box, use_container_width=True ) - - # Remove remaining objects we've finished with to minimize memory usage + + # Remove remaining objects we've finished with to minimize memory usage del results - gc.collect() \ No newline at end of file + gc.collect() diff --git "a/pages/4_\360\237\217\245_The_Full_Model.py" "b/pages/4_\360\237\217\245_The_Full_Model.py" index b1e7e17..5c40f65 100644 --- "a/pages/4_\360\237\217\245_The_Full_Model.py" +++ "b/pages/4_\360\237\217\245_The_Full_Model.py" @@ -1,7 +1,9 @@ ''' -A Streamlit application based on Monks and +A Streamlit application based on the open treatment centre simulation model from Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) -Allows users to interact with an increasingly more complex treatment simulation +Original Model: https://github.com/TomMonks/treatment-centre-sim/tree/main + +Allows users to interact with an increasingly complex treatment simulation ''' import gc import asyncio @@ -9,10 +11,10 @@ import plotly.express as px import plotly.graph_objects as go import streamlit as st - +import numpy as np from helper_functions import add_logo, mermaid, center_running from model_classes import Scenario, multiple_replications -from output_animation_functions import reshape_for_animations, animate_activity_log +from output_animation_functions import reshape_for_animations, generate_animation_df, generate_animation st.set_page_config( page_title="The Full Model", @@ -40,21 +42,21 @@ gc.collect() # tab1, tab2, tab3, tab4 = st.tabs(["Introduction", "Exercises", "Playground", "Compare Scenario Outputs"]) -tab1, tab2, tab3, tab4 = st.tabs(["Playground", "Exercise", "Compare Scenario Outputs", "Information"]) +tab4, tab2, tab1, tab3 = st.tabs(["Information", "Exercise", "Playground", "Compare Scenario Outputs"]) with tab4: st.markdown(""" So now we have explored every component of the model: - Generating arrivals - Generating and using resources - - Sending people down different paths - - So now let's create a version of the model that uses all of these aspects. + - Sending people down different paths + + So now let's create a version of the model that uses all of these aspects. For now, we won't consider nurses separately - we will assume that each nurse on shift has one room that is theirs to always use. """ ) - + mermaid(height=600, code= """ %%{ init: { 'flowchart': { 'curve': 'step' } } }%% @@ -65,20 +67,20 @@ T -.-> BX BX --> BY{Trauma or non-trauma} - BY ----> B1{Trauma Pathway} + BY ----> B1{Trauma Pathway} BY ----> B2{Non-Trauma Pathway} - + B1 --> C[Stabilisation] C --> E[Treatment] - + B2 --> D[Registration] D --> G[Examination] G --> H[Treat?] - H ----> F + H ----> F H --> I[Non-Trauma Treatment] - I --> F + I --> F C -.-> Z([Trauma Room\nRESOURCE]) Z -.-> C @@ -117,25 +119,25 @@ class T ZZ5a ; """ ) - + with tab2: st.header("Things to Try") st.markdown( """ - - First, just run the model with the default settings. + - First, just run the model with the default settings. - Look at the graphs and animated patient log. What is the performance of the system like? - Are the queues consistent throughout the day? --- - - Due to building work taking place, the hospital will temporarily need to close several bays. - It will be possible to have a maximum of 20 bays/cubicles/rooms in total across the whole system. + - Due to building work taking place, the hospital will temporarily need to close several bays. + It will be possible to have a maximum of 20 bays/cubicles/rooms in total across the whole system. - What is the best configuration you can find to keep the average wait times as low as possible across both trauma and non-trauma pathways? *Make sure you are using the default probabilities for trauma/non-trauma patients (0.3) and treatment of non-trauma patients (0.7)* """ ) with tab1: - + # n_triage: int # The number of triage cubicles @@ -159,14 +161,14 @@ class T ZZ5a # prob_trauma: float # probability that a new arrival is a trauma patient. - + col1, col2, col3, col4 = st.columns(4) with col1: st.subheader("Triage") n_triage = st.slider("👨‍⚕️👩‍⚕️ Number of Triage Cubicles", 1, 10, step=1, value=4) - prob_trauma = st.slider("🚑 Probability that a new arrival is a trauma patient", + prob_trauma = st.slider("🚑 Probability that a new arrival is a trauma patient", 0.0, 1.0, step=0.01, value=0.3, help="0 = No arrivals are trauma patients\n\n1 = All arrivals are trauma patients") @@ -180,10 +182,10 @@ class T ZZ5a n_reg = st.slider("👨‍⚕️👩‍⚕️ Number of Registration Cubicles", 1, 10, step=1, value=3) n_exam = st.slider("👨‍⚕️👩‍⚕️ Number of Examination Rooms for non-trauma patients", 1, 10, step=1, value=3) - with col4: + with col4: st.subheader("Non-Trauma Treatment") n_cubicles_1 = st.slider("👨‍⚕️👩‍⚕️ Number of Treatment Cubicles for Non-Trauma", 1, 10, step=1, value=2) - non_trauma_treat_p = st.slider("🤕 Probability that a non-trauma patient will need treatment", + non_trauma_treat_p = st.slider("🤕 Probability that a non-trauma patient will need treatment", 0.0, 1.0, step=0.01, value=0.7, help="0 = No non-trauma patients need treatment\n\n1 = All non-trauma patients need treatment") @@ -200,7 +202,7 @@ class T ZZ5a n_reps = st.slider("🔁 How many times should the simulation run? WARNING: Fast/modern computer required to take this above 5 replications.", 1, 10, step=1, value=3) - + run_time_days = st.slider("🗓️ How many days should we run the simulation for each time?", 1, 60, step=1, value=5) @@ -215,11 +217,11 @@ class T ZZ5a n_cubicles_2=n_cubicles_2, non_trauma_treat_p=non_trauma_treat_p, prob_trauma=prob_trauma) - + # A user must press a streamlit button to run the model button_run_pressed = st.button("Run simulation") - - + + if button_run_pressed: # add a spinner and then display success box @@ -241,13 +243,13 @@ class T ZZ5a results = pd.concat([detailed_outputs[i]['results']['summary_df'].assign(rep= i+1) for i in range(n_reps)]).set_index('rep') - + full_event_log = pd.concat([detailed_outputs[i]['results']['full_event_log'].assign(rep= i+1) for i in range(n_reps)]) - + del detailed_outputs gc.collect() - + my_bar.progress(60, text="Logging Results...") # print(len(st.session_state['session_results'])) @@ -267,7 +269,7 @@ class T ZZ5a # Reorder columns column_order = ['Model Run', 'Triage\nCubicles', 'Registration\nClerks', 'Examination\nRooms', - 'Non-Trauma\nTreatment Cubicles', 'Trauma\nStabilisation Bays', + 'Non-Trauma\nTreatment Cubicles', 'Trauma\nStabilisation Bays', 'Trauma\nTreatment Cubicles', 'Probability patient\nis a trauma patient', 'Probability non-trauma patients\nrequire treatment', 'Random Seed' ] + list(original_cols) @@ -277,7 +279,7 @@ class T ZZ5a current_state = st.session_state['session_results'] current_state.append(results_for_state) - + del results_for_state gc.collect() @@ -291,7 +293,7 @@ class T ZZ5a # UTILISATION AUDIT - BRING BACK WHEN NEEDED # full_utilisation_audit = pd.concat([detailed_outputs[i]['results']['utilisation_audit'].assign(Rep= i+1) # for i in range(n_reps)]) - + # animation_dfs_queue = reshape_for_animations( # full_event_log[ # (full_event_log['rep']==1) & @@ -302,14 +304,14 @@ class T ZZ5a my_bar.progress(80, text="Creating Animations...") animation_dfs_log = reshape_for_animations( - full_event_log=full_event_log[ + event_log=full_event_log[ (full_event_log['rep']==1) & - ((full_event_log['event_type']=='queue') | (full_event_log['event_type']=='resource_use') | (full_event_log['event_type']=='arrival_departure')) & - # Limit to first 5 days - (full_event_log['time'] <= 60*24*5) + ((full_event_log['event_type']=='queue') | (full_event_log['event_type']=='resource_use') | (full_event_log['event_type']=='arrival_departure')) ], - every_x_minutes=5 - )['full_patient_df'] + step_snapshot_max=30, + every_x_time_units=5, + limit_duration=60*24*5 + ) del full_event_log gc.collect() @@ -330,7 +332,7 @@ class T ZZ5a "Animated Log", "Advanced Graphs" ]) - + # st.markdown(""" # You can click on the three tabs below ("Simple Graphs", "Animated Log", and "Advanced Graphs") to view different outputs from the model. # """) @@ -338,44 +340,44 @@ class T ZZ5a # st.subheader("Look at Average Results Across Replications") with tab_playground_results_2: - + event_position_df = pd.DataFrame([ # {'event': 'arrival', 'x': 10, 'y': 250, 'label': "Arrival" }, - - # Triage - minor and trauma - {'event': 'triage_wait_begins', + + # Triage - minor and trauma + {'event': 'triage_wait_begins', 'x': 160, 'y': 400, 'label': "Waiting for
Triage" }, - {'event': 'triage_begins', + {'event': 'triage_begins', 'x': 160, 'y': 315, 'resource':'n_triage', 'label': "Being Triaged" }, - - # Minors (non-trauma) pathway - {'event': 'MINORS_registration_wait_begins', - 'x': 300, 'y': 145, 'label': "Waiting for
Registration" }, - {'event': 'MINORS_registration_begins', - 'x': 300, 'y': 85, 'resource':'n_reg', 'label':'Being
Registered' }, - - {'event': 'MINORS_examination_wait_begins', - 'x': 465, 'y': 145, 'label': "Waiting for
Examination" }, - {'event': 'MINORS_examination_begins', - 'x': 465, 'y': 85, 'resource':'n_exam', 'label': "Being
Examined" }, - - {'event': 'MINORS_treatment_wait_begins', - 'x': 630, 'y': 145, 'label': "Waiting for
Treatment" }, - {'event': 'MINORS_treatment_begins', - 'x': 630, 'y': 85, 'resource':'n_cubicles_1', 'label': "Being
Treated" }, + + # Minors (non-trauma) pathway + {'event': 'MINORS_registration_wait_begins', + 'x': 290, 'y': 145, 'label': "Waiting for
Registration" }, + {'event': 'MINORS_registration_begins', + 'x': 290, 'y': 85, 'resource':'n_reg', 'label':'Being
Registered' }, + + {'event': 'MINORS_examination_wait_begins', + 'x': 460, 'y': 145, 'label': "Waiting for
Examination" }, + {'event': 'MINORS_examination_begins', + 'x': 460, 'y': 85, 'resource':'n_exam', 'label': "Being
Examined" }, + + {'event': 'MINORS_treatment_wait_begins', + 'x': 625, 'y': 145, 'label': "Waiting for
Treatment" }, + {'event': 'MINORS_treatment_begins', + 'x': 625, 'y': 85, 'resource':'n_cubicles_1', 'label': "Being
Treated" }, # Trauma pathway - {'event': 'TRAUMA_stabilisation_wait_begins', - 'x': 300, 'y': 560, 'label': "Waiting for
Stabilisation" }, - {'event': 'TRAUMA_stabilisation_begins', - 'x': 300, 'y': 500, 'resource':'n_trauma', 'label': "Being
Stabilised" }, + {'event': 'TRAUMA_stabilisation_wait_begins', + 'x': 290, 'y': 560, 'label': "Waiting for
Stabilisation" }, + {'event': 'TRAUMA_stabilisation_begins', + 'x': 290, 'y': 500, 'resource':'n_trauma', 'label': "Being
Stabilised" }, - {'event': 'TRAUMA_treatment_wait_begins', - 'x': 630, 'y': 560, 'label': "Waiting for
Treatment" }, - {'event': 'TRAUMA_treatment_begins', - 'x': 630, 'y': 500, 'resource':'n_cubicles_2', 'label': "Being
Treated" }, + {'event': 'TRAUMA_treatment_wait_begins', + 'x': 625, 'y': 560, 'label': "Waiting for
Treatment" }, + {'event': 'TRAUMA_treatment_begins', + 'x': 625, 'y': 500, 'resource':'n_cubicles_2', 'label': "Being
Treated" }, - {'event': 'exit', + {'event': 'exit', 'x': 670, 'y': 330, 'label': "Exit"} ]) @@ -383,29 +385,36 @@ class T ZZ5a st.markdown( """ - The plot below shows a snapshot every 5 minutes of the position of everyone in our emergency department model. - - The buttons to the left of the slider below the plot can be used to start and stop the animation. + The plot below shows a snapshot every 5 minutes of the position of everyone in our emergency department model. - Clicking on the bar below the plot and dragging your cursor to the left or right allows you to rapidly jump through to a different time in the simulation. + The buttons to the left of the slider below the plot can be used to start and stop the animation. - Only the first replication of the simulation is shown. + Clicking on the bar below the plot and dragging your cursor to the left or right allows you to rapidly jump through to a different time in the simulation. + + Only the first replication of the simulation is shown. """ ) - animated_plot = animate_activity_log( - full_patient_df=animation_dfs_log[animation_dfs_log["minute"]<=60*24*5], + full_patient_df_plus_pos = generate_animation_df( + full_patient_df=animation_dfs_log, + event_position_df = event_position_df, + wrap_queues_at=10, + gap_between_entities=10, + gap_between_rows=25, + step_snapshot_max=30 + ) + + animated_plot = generate_animation( + full_patient_df_plus_pos=full_patient_df_plus_pos, event_position_df = event_position_df, scenario=args, include_play_button=True, - return_df_only=False, plotly_height=900, plotly_width=1600, override_x_max=700, override_y_max=675, - icon_and_text_size=24, + icon_and_text_size=19, display_stage_labels=False, - wrap_queues_at=10, time_display_units="dhm", # show_animated_clock=True, # animated_clock_coordinates = [100, 50], @@ -455,7 +464,7 @@ class T ZZ5a in_range_util = sum((results.mean().filter(like="util")<0.85) & (results.mean().filter(like="util") > 0.65)) in_range_wait = sum((results.mean().filter(like="wait")<120)) - + col_res_a, col_res_b = st.columns([1,1]) @@ -465,12 +474,12 @@ class T ZZ5a #util_fig_simple = px.bar(results.mean().filter(like="util"), opacity=0.5) st.markdown( """ - The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. - - The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. - - If utilisation is below this, you might want to **reduce** the number of those resources available. - + The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. + + The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. + + If utilisation is below this, you might want to **reduce** the number of those resources available. + If utilisation is above this point, you may want to **increase** the number of that type of resource available. """ ) @@ -491,12 +500,13 @@ class T ZZ5a util_fig_simple.add_bar(x=results.mean().filter(like="util").index.tolist(), y=results.mean().filter(like="util").tolist()) - util_fig_simple.update_layout(yaxis_tickformat = '.0%') + util_fig_simple.update_layout(yaxis_tickformat = '.0%', + title=dict(text="Utilisation of Resources - Average Across Simulation Runs", automargin=True, yref='paper')) util_fig_simple.update_yaxes(title_text='Resource Utilisation (%)', range=[-0.05, 1.1]) # util_fig_simple.data = util_fig_simple.data[::-1] util_fig_simple.update_xaxes(labelalias={ - "01b_triage_util": "Triage
Bays", + "01b_triage_util": "Triage
Bays", "02b_registration_util": "Registration
Cubicles", "03b_examination_util": "Examination
Bays", "04b_treatment_util(non_trauma)": "Treatment
Bays
(non-trauma)", @@ -508,7 +518,7 @@ class T ZZ5a use_container_width=True ) - + with col_res_b: #util_fig_simple = px.bar(results.mean().filter(like="wait"), opacity=0.5) st.metric(label=":clock2: **Wait Metrics in Ideal Range**", value="{} of {}".format(in_range_wait, len(results.mean().filter(like="wait")))) @@ -516,22 +526,22 @@ class T ZZ5a st.markdown( """ The emergency department wants to ensure people wait no longer than 2 hours (120 minutes) at any point in the process. - + This needs to be balanced with the utilisation graphs on the left. - + The green box shows waits of less than two hours. If the bars fall within this range, the number of resources does not need to be changed. """ ) wait_fig_simple = go.Figure() - wait_fig_simple.add_hrect(y0=0, y1=60*2, fillcolor="#5DFDA0", + wait_fig_simple.add_hrect(y0=0, y1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) - + wait_fig_simple.add_bar(x=results.mean().filter(like="wait").index.tolist(), y=results.mean().filter(like="wait").tolist()) wait_fig_simple.update_xaxes(labelalias={ - "01a_triage_wait": "Triage", + "01a_triage_wait": "Triage", "02a_registration_wait": "Registration", "03a_examination_wait": "Examination", "04a_treatment_wait(non_trauma)": "Treatment
(non-trauma)", @@ -541,6 +551,8 @@ class T ZZ5a # wait_fig_simple.data = wait_fig_simple.data[::-1] wait_fig_simple.update_yaxes(title_text='Wait for Treatment Stage (Minutes)') + wait_fig_simple.update_layout(title=dict(text="Waits at Each Step - Average Across Simulation Runs", automargin=True, yref='paper')) + st.plotly_chart( wait_fig_simple, use_container_width=True @@ -551,10 +563,10 @@ class T ZZ5a st.markdown(""" We can use box plots to explore the effect of the random variation within each model run. - - This can give us a better idea of how robust the system is. - - Each dot indicates a single model run. The number of runs can be increased under the advanced options. + + This can give us a better idea of how robust the system is. + + Each dot indicates a single model run. The number of runs can be increased under the advanced options. """) col_res_1, col_res_2 = st.columns(2) @@ -564,23 +576,23 @@ class T ZZ5a st.markdown( """ - The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. - - The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. - - If utilisation is below this, you might want to **reduce** the number of those resources available. - + The emergency department wants to aim for an average of 65% to 85% utilisation across all resources in the emergency department. + + The green box shows this ideal range. If the bars overlap with the green box, utilisation is ideal. + + If utilisation is below this, you might want to **reduce** the number of those resources available. + If utilisation is above this point, you may want to **increase** the number of that type of resource available. """ ) utilisation_boxplot = px.box( - results.reset_index().melt(id_vars="rep").set_index('variable').filter(like="util", axis=0).reset_index(), - y="variable", + results.reset_index().melt(id_vars="rep").set_index('variable').filter(like="util", axis=0).reset_index(), + y="variable", x="value", points="all", range_x=[0, 1]) - + utilisation_boxplot.add_vrect(x0=0.65, x1=0.85, fillcolor="#5DFDA0", opacity=0.25, line_width=0) # Add extreme range (above) @@ -594,7 +606,7 @@ class T ZZ5a fillcolor="#D45E5E", opacity=0.25, line_width=0) utilisation_boxplot.update_yaxes(labelalias={ - "01b_triage_util": "Triage
Bays", + "01b_triage_util": "Triage
Bays", "02b_registration_util": "Registration
Cubicles", "03b_examination_util": "Examination
Bays", "04b_treatment_util(non_trauma)": "Treatment
Bays
(non-trauma)", @@ -606,40 +618,40 @@ class T ZZ5a range=[-0.05, 1.1]) utilisation_boxplot.update_layout(xaxis_tickformat = '.0%') - + st.plotly_chart(utilisation_boxplot, use_container_width=True ) - + st.write(results.filter(like="util", axis=1) .merge(results.filter(like="throughput", axis=1), left_index=True,right_index=True) .T.rename_axis('Metric', axis=0) ) - + with col_res_2: st.subheader("Average Waits") st.markdown( """ The emergency department wants to ensure people wait no longer than 2 hours (120 minutes) at any point in the process. - + This needs to be balanced with the utilisation graphs on the left. - + The green box shows waits of less than two hours. If the bars fall within this range, the number of resources does not need to be changed. """ ) wait_boxplot = px.box( results.reset_index().melt(id_vars="rep").set_index('variable') - .filter(like="wait", axis=0).reset_index(), - y="variable", + .filter(like="wait", axis=0).reset_index(), + y="variable", x="value", points="all") wait_boxplot.update_yaxes(labelalias={ - "01a_triage_wait": "Triage", + "01a_triage_wait": "Triage", "02a_registration_wait": "Registration", "03a_examination_wait": "Examination", "04a_treatment_wait(non_trauma)": "Treatment
(non-trauma)", @@ -647,9 +659,9 @@ class T ZZ5a "07a_treatment_wait(trauma)": "Treatment
(trauma)" }, tickangle=0, title_text='') - wait_boxplot.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", + wait_boxplot.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) - + wait_boxplot.update_xaxes(title_text='Wait for Treatment Stage (Minutes)') # Add in a box plot showing waits @@ -658,16 +670,16 @@ class T ZZ5a ) st.write(results.filter(like="wait", axis=1) - .merge(results.filter(like="throughput", axis=1), + .merge(results.filter(like="throughput", axis=1), left_index=True, right_index=True) .T.rename_axis('Metric', axis=0)) - + # with tab_playground_results_4: # st.markdown("Placeholder") - + # del results # gc.collect() @@ -679,29 +691,31 @@ class T ZZ5a all_run_results = pd.concat(st.session_state['session_results']) + st.markdown("If you would like to clear the simulation history in this tab, refresh the page.") + st.subheader("Look at Average Results Across Replications") # col_a, col_b = st.columns(2) - + # with col_a: parameter_scenario_df = all_run_results.groupby('Model Run').median().T.reset_index(drop=False) parameter_scenario_df.columns = [f"Scenario {i}" for i in parameter_scenario_df.columns] parameter_scenario_df = parameter_scenario_df[~parameter_scenario_df['Scenario index'].str.contains("\d", regex=True)] - + st.dataframe(parameter_scenario_df.set_index(parameter_scenario_df.columns[0]).rename_axis('Parameter', axis=0), hide_index=False, use_container_width=True) del parameter_scenario_df scenario_tab_1, scenario_tab_2, scenario_tab_3 = st.tabs([ - "Simple Metrics", + "Simple Metrics", "Advanced Metrics", "Detailed Breakdown"]) with scenario_tab_1: # st.write( -# all_run_results.groupby('Model Run').median().T.reset_index(drop=False).melt(id_vars="index", var_name="model_run"), x="variable", +# all_run_results.groupby('Model Run').median().T.reset_index(drop=False).melt(id_vars="index", var_name="model_run"), x="variable", # ) col_x, col_y = st.columns(2) @@ -709,14 +723,14 @@ class T ZZ5a st.subheader("Utilisation") all_run_util_bar = px.bar( - all_run_results.groupby('Model Run').median().T.filter(like="util", axis=0).reset_index(drop=False).melt(id_vars="index", var_name="model_run"), + all_run_results.groupby('Model Run').median().T.filter(like="util", axis=0).reset_index(drop=False).melt(id_vars="index", var_name="model_run"), x="value", y="index", barmode='group', color="model_run", - range_x=[0, 1], + range_x=[0, 1], height=800) - + all_run_util_bar.add_vrect(x0=0.65, x1=0.85, fillcolor="#5DFDA0", opacity=0.25, line_width=0) # Add extreme range (above) @@ -730,7 +744,7 @@ class T ZZ5a fillcolor="#D45E5E", opacity=0.25, line_width=0) all_run_util_bar.update_yaxes(labelalias={ - "01b_triage_util": "Triage
Bays", + "01b_triage_util": "Triage
Bays", "02b_registration_util": "Registration
Cubicles", "03b_examination_util": "Examination
Bays", "04b_treatment_util(non_trauma)": "Treatment
Bays
(non-trauma)", @@ -740,27 +754,27 @@ class T ZZ5a all_run_util_bar.update_xaxes(title_text='Resource Utilisation (%)') all_run_util_bar.update_layout(xaxis_tickformat = '.0%', - legend_title_text='Model Run') + legend_title_text='Scenario') st.plotly_chart( all_run_util_bar, use_container_width=True ) - + with col_y: st.subheader("Waits") all_run_wait_bar = px.bar( - all_run_results.groupby('Model Run').median().T.filter(like="wait", axis=0).reset_index(drop=False).melt(id_vars="index", var_name="model_run"), + all_run_results.groupby('Model Run').median().T.filter(like="wait", axis=0).reset_index(drop=False).melt(id_vars="index", var_name="model_run"), x="value", y="index", barmode='group', - color="model_run", + color="model_run", height=800 ) - + all_run_wait_bar.update_yaxes(labelalias={ - "01a_triage_wait": "Triage", + "01a_triage_wait": "Triage", "02a_registration_wait": "Registration", "03a_examination_wait": "Examination", "04a_treatment_wait(non_trauma)": "Treatment
(non-trauma)", @@ -770,33 +784,33 @@ class T ZZ5a all_run_wait_bar.update_xaxes(title_text='Wait for Stage (minutes)') - all_run_wait_bar.update_layout(legend_title_text='Model Run') + all_run_wait_bar.update_layout(legend_title_text='Scenario') - all_run_wait_bar.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", + all_run_wait_bar.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) st.plotly_chart(all_run_wait_bar, use_container_width=True ) - + # Repeat but with boxplots instead so variability within model runs can be # better explored with scenario_tab_2: col_res_1, col_res_2 = st.columns(2) - + with col_res_1: st.subheader("Utilisation") all_run_util_box = px.box( - all_run_results.reset_index().melt(id_vars=["Model Run", "rep"]).set_index('variable').filter(like="util", axis=0).reset_index(), - y="variable", + all_run_results.reset_index().melt(id_vars=["Model Run", "rep"]).set_index('variable').filter(like="util", axis=0).reset_index(), + y="variable", x="value", color="Model Run", points="all", - range_x=[0, 1], + range_x=[0, 1], height=800) all_run_util_box.add_vrect(x0=0.65, x1=0.85, @@ -812,7 +826,7 @@ class T ZZ5a fillcolor="#D45E5E", opacity=0.25, line_width=0) all_run_util_box.update_yaxes(labelalias={ - "01b_triage_util": "Triage
Bays", + "01b_triage_util": "Triage
Bays", "02b_registration_util": "Registration
Cubicles", "03b_examination_util": "Examination
Bays", "04b_treatment_util(non_trauma)": "Treatment
Bays
(non-trauma)", @@ -822,31 +836,31 @@ class T ZZ5a all_run_util_box.update_xaxes(title_text='Resource Utilisation (%)') all_run_util_box.update_layout(xaxis_tickformat = '.0%', - legend_title_text='Model Run') + legend_title_text='Scenario') st.plotly_chart(all_run_util_box, use_container_width=True ) - + # st.write(all_run_results.filter(like="util", axis=1).merge(all_run_results.filter(like="throughput", axis=1),left_index=True,right_index=True)) - + with col_res_2: st.subheader("Waits") all_run_wait_box = px.box( - all_run_results.reset_index().melt(id_vars=["Model Run", "rep"]).set_index('variable').filter(like="wait", axis=0).reset_index(), - # left_index=True, right_index=True), - y="variable", + all_run_results.reset_index().melt(id_vars=["Model Run", "rep"]).set_index('variable').filter(like="wait", axis=0).reset_index(), + # left_index=True, right_index=True), + y="variable", x="value", color="Model Run", - points="all", + points="all", height=800) - + all_run_wait_box.update_yaxes(labelalias={ - "01a_triage_wait": "Triage", + "01a_triage_wait": "Triage", "02a_registration_wait": "Registration", "03a_examination_wait": "Examination", "04a_treatment_wait(non_trauma)": "Treatment
(non-trauma)", @@ -855,10 +869,10 @@ class T ZZ5a }, tickangle=0, title_text='') all_run_wait_box.update_xaxes(title_text='Wait for Stage (minutes)') - all_run_wait_box.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", + all_run_wait_box.add_vrect(x0=0, x1=60*2, fillcolor="#5DFDA0", opacity=0.3, line_width=0) - - all_run_wait_box.update_layout(legend_title_text='Model Run') + + all_run_wait_box.update_layout(legend_title_text='Scenario') # Add in a box plot showing waits st.plotly_chart(all_run_wait_box, @@ -881,16 +895,16 @@ class T ZZ5a all_results_throughput_box = px.box( - all_run_results.reset_index().melt(id_vars=["Model Run", "rep"]).set_index('variable').filter(like="perc_throughput", axis=0).reset_index(), - y="variable", + all_run_results.reset_index().melt(id_vars=["Model Run", "rep"]).set_index('variable').filter(like="perc_throughput", axis=0).reset_index(), + y="variable", x="value", color="Model Run", points="all", height=800) - + all_results_throughput_box.update_layout(xaxis_tickformat = '.0%', - legend_title_text='Model Run') - + legend_title_text='Scenario') + all_run_util_bar.update_yaxes(title_text='', labelalias={ "perc_throughout": "Throughput (% of arrivals
that exit before model end)"}) @@ -904,7 +918,7 @@ class T ZZ5a ) # st.write(all_run_results.filter(like="wait", axis=1) - # .merge(all_run_results.filter(like="throughput", axis=1), + # .merge(all_run_results.filter(like="throughput", axis=1), # left_index=True, right_index=True)) with scenario_tab_3: @@ -915,7 +929,7 @@ class T ZZ5a st.markdown("This displays the median value for each metric across all model runs per scenario.") - import numpy as np + output_scenario_df = all_run_results.groupby('Model Run').median().T output_scenario_df = output_scenario_df.reset_index(drop=False).melt(id_vars="index") # st.dataframe(output_scenario_df) @@ -933,7 +947,7 @@ class T ZZ5a output_scenario_df.columns = [f"Scenario {i}" for i in output_scenario_df.columns] # st.dataframe(output_scenario_df) output_scenario_df = output_scenario_df[output_scenario_df['Scenario index'].str.contains("\d", regex=True)] - + output_scenario_df['Scenario index'] = output_scenario_df['Scenario index'].apply(lambda x: (x.replace('_', ' ')).title()) st.dataframe(output_scenario_df.set_index(output_scenario_df.columns[0]).rename_axis('Metric', axis=0), @@ -947,4 +961,4 @@ class T ZZ5a else: st.markdown("No scenarios yet run. Go to the 'Playground' tab and click 'Run simulation'.") -gc.collect() \ No newline at end of file +gc.collect() diff --git "a/pages/7_\360\237\222\241_Find_Out_More.py" "b/pages/7_\360\237\222\241_Find_Out_More.py" index 4b55c83..4724f7a 100644 --- "a/pages/7_\360\237\222\241_Find_Out_More.py" +++ "b/pages/7_\360\237\222\241_Find_Out_More.py" @@ -1,7 +1,13 @@ ''' -A Streamlit application based on Monks and +A page containing information about -Allows users to interact with an increasingly more complex treatment simulation +--- + +A Streamlit application based on the open treatment centre simulation model from Monks.T, Harper.A, Anagnoustou. A, Allen.M, Taylor.S. (2022) + +Original Model: https://github.com/TomMonks/treatment-centre-sim/tree/main + +Allows users to interact with an increasingly complex treatment centre simulation ''' import gc import streamlit as st @@ -28,7 +34,7 @@ st.markdown( """ - Joining the HSMA programme will give you access to + Joining the HSMA programme will give you access to - three sessions on discrete event simulation - a session on system dynamics modelling (for large, system-scale problems) - sessions on cellular automata and agent-based simulation (for modelling interactions and motivations of individuals that lead to high-level patterns, e.g. to look at the spread of disease) @@ -45,7 +51,7 @@ However - if you apply to HSMA 6, you will get the benefit of support from the HSMA team as well as a peer support group. - We're always revising our content, so HSMA 6 will be bigger and better than ever before! + We're always revising our content, so HSMA 6 will be bigger and better than ever before! """ ) @@ -73,6 +79,12 @@ Finally, this whole exercise website has been written in Streamlit - another topic we cover on the course! Streamlit allows you to create highly customisable websites that allow you to share results with users and give them the freedom to interact with powerful Python code without needing Python on their own computers. - Two brand new sessions on Streamlit are being created for HSMA 6 - so apply now if you want to find out more! + Two brand new sessions on Streamlit are being created for HSMA 6 - so apply now if you want to find out more! """ -) \ No newline at end of file +) + +st.subheader("Where Can I Find the Code for this Model?") + +st.markdown("All of the code used to make this model, the visualisation and the streamlit app can be found in [this GitHub repository](https://github.com/hsma-programme/Teaching_DES_Concepts_Streamlit).") + +st.markdown("The code is available under the MIT licence so may be freely used and adapted - though we'd love to hear about it if you do use the app or code!")