Mental Health - Appointment Booking Model

Warning

This example has not yet been updated to reflect all of the new features and recommendations from vidigi 2.0.0, which have further simplified the process of adding vidigi to your model and accessing and modifying your animation.

For now, all of the code below will still work, but check out the getting started page for a full guide to the recommended way to use vidigi 2.0.0.

Sometimes you may want to display models without having a clear concept of a ‘resource’ that you track.

In this case, vidigi can cope perfectly fine with just having ‘queue’ steps implemented.

In this instance, this model looks at a simple mental health pathway. Here, we are only concerned with the booking of an initial appointment. Each clinician has a certain number of slots available per day, but the number of available slots varies significantly across the course of the week for each individual clinician. Changing the available resources over the course of a simulation is not currently supported - though you could demonstrate resources being ‘unavailable’ by blocking them for a certain duration with an icon indicating unavailability. In this case, where the focus is on the buildup of queues,

This example also shows the automatic generation of the appropriate number of event positioning entries, which would support the use of the animation in a front-end where the number of clinicains could be varied.

By default, the model uses an appointment book with some slots held back for high-priority patients. Each patient in the default scenario can only go to their ‘home’/most local clinic.

However, it is possible to switch to other scenarios - a ‘pooling’ system where patients can choose between one of several linked clinics in their local area (with the assumption that they will choose the clinic of the group with the soonest available appointment) - the pooling system described above, but with no slots held back for high-priority patients (i.e. no ‘carve-out’)

import math

import pandas as pd
import plotly.io as pio
from model_classes import Scenario, generate_seed_vector
from simulation_execution_functions import single_run

from vidigi.animation import generate_animation
from vidigi.prep import generate_animation_df, reshape_for_animations

pio.renderers.default = "iframe"
shifts = pd.read_csv("data/shifts.csv")
# if scenario_choice == "As-is" or scenario_choice == "With Pooling":
# prop_carve_out = [0.0, 0.9, 0.15, 0.01]
prop_carve_out = 0.15

# depending on settings and CPU this model takes around 15-20 seconds to run
RESULTS_COLLECTION = 90 * 1

# We use a warm-up period
# because the model starts up empty which doesn't reflect reality
WARM_UP = 60 * 1
RUN_LENGTH = RESULTS_COLLECTION + WARM_UP

# Set up the scenario for the model to run.
scenarios = {}

scenarios["as-is"] = Scenario(
    RUN_LENGTH,
    WARM_UP,
    prop_carve_out=prop_carve_out,
    seeds=generate_seed_vector(),
    slots_file=shifts,
)

scenarios["pooled"] = Scenario(
    RUN_LENGTH,
    WARM_UP,
    prop_carve_out=prop_carve_out,
    pooling=True,
    seeds=generate_seed_vector(),
    slots_file=shifts,
)

scenarios["no_carve_out"] = Scenario(
    RUN_LENGTH,
    WARM_UP,
    pooling=True,
    prop_carve_out=0.0,
    seeds=generate_seed_vector(),
    slots_file=shifts,
)

clinic_lkup_df = pd.DataFrame(
    [
        {"clinic": 0, "icon": "🟠"},
        {"clinic": 1, "icon": "🟡"},
        {"clinic": 2, "icon": "🟢"},
        {"clinic": 3, "icon": "🔵"},
        {"clinic": 4, "icon": "🟣"},
        {"clinic": 5, "icon": "🟤"},
        {"clinic": 6, "icon": "⚫"},
        {"clinic": 7, "icon": "⚪"},
        {"clinic": 8, "icon": "🔶"},
        {"clinic": 9, "icon": "🔷"},
        {"clinic": 10, "icon": "🟩"},
    ]
)


def show_home_clinic(row):
    if "more" not in row["icon"]:
        if row["home_clinic"] == 0:
            return "🟠"
        if row["home_clinic"] == 1:
            return "🟡"
        if row["home_clinic"] == 2:
            return "🟢"
        if row["home_clinic"] == 3:
            return "🔵"
        if row["home_clinic"] == 4:
            return "🟣"
        if row["home_clinic"] == 5:
            return "🟤"
        if row["home_clinic"] == 6:
            return "⚫"
        if row["home_clinic"] == 7:
            return "⚪"
        if row["home_clinic"] == 8:
            return "🔶"
        if row["home_clinic"] == 9:
            return "🔷"
        if row["home_clinic"] == 10:
            return "🟩"
        else:
            return row["icon"]
    else:
        return row["icon"]


def show_priority_icon(row):
    if "more" not in row["icon"]:
        if row["pathway"] == 2:
            return "🚨"
        else:
            return row["icon"]
    else:
        return row["icon"]


def add_los_to_icon(row):
    if row["event_original"] == "have_appointment":
        return f"{row['icon']}<br>{int(row['wait'])}"
    else:
        return row["icon"]
def generate_scenario_results(scenario):
    results_all, results_low, results_high, event_log = single_run(scenarios[scenario])
    event_log_df = pd.DataFrame(event_log)

    event_log_df["event_original"] = event_log_df["event"]

    event_log_df["event"] = event_log_df.apply(
        lambda x: (
            f"{x['event']}{f'_{int(x.booked_clinic)}' if pd.notna(x['booked_clinic']) else ''}"
        ),
        axis=1,
    )

    event_log_df.to_csv(f"{scenario}.csv")

    full_patient_df = reshape_for_animations(
        event_log_df,
        entity_col_name="patient",
        event_col_name="event",
        event_type_col_name="event_type",
        limit_duration=RUN_LENGTH,
        every_x_time_units=1,
        step_snapshot_max=50,
        warm_up=WARM_UP,
    )

    # Auto-generate event positions for every possible clinic and event combination
    clinics = [
        x
        for x in event_log_df["booked_clinic"].sort_values().unique().tolist()
        if not math.isnan(x)
    ]

    clinic_waits = [
        {
            "event": f"appointment_booked_waiting_{int(clinic)}",
            "y": 950 - (clinic + 1) * 80,
            "x": 625,
            "label": f"Booked into<br>clinic {int(clinic)}",
            "clinic": int(clinic),
        }
        for clinic in clinics
    ]

    clinic_attends = [
        {
            "event": f"have_appointment_{int(clinic)}",
            "y": 950 - (clinic + 1) * 80,
            "x": 850,
            "label": f"Attending appointment<br>at clinic {int(clinic)}",
        }
        for clinic in clinics
    ]

    event_position_df = pd.concat(
        [pd.DataFrame(clinic_waits), (pd.DataFrame(clinic_attends))]
    )

    referred_out = [
        {
            "event": f"referred_out_{int(clinic)}",
            "y": 950 - (clinic + 1) * 80,
            "x": 125,
            "label": f"Referred Out From <br>clinic {int(clinic)}",
        }
        for clinic in clinics
    ]

    event_position_df = pd.concat([event_position_df, (pd.DataFrame(referred_out))])

    if scenario in ["pooled", "no_carve_out"]:
        event_position_df = event_position_df.merge(clinic_lkup_df, how="left")
        event_position_df["label"] = event_position_df.apply(
            lambda x: (
                f"{x['label']} {x['icon']}" if pd.notna(x["icon"]) else x["label"]
            ),
            axis=1,
        )
        event_position_df = event_position_df.drop(columns="icon")

    event_position_df = event_position_df.drop(columns="clinic")

    # Generate the final animation

    full_patient_df_plus_pos = generate_animation_df(
        full_entity_df=full_patient_df,
        entity_col_name="patient",
        event_col_name="event",
        event_type_col_name="event_type",
        event_position_df=event_position_df,
        wrap_queues_at=25,
        step_snapshot_max=50,
        gap_between_entities=15,
        gap_between_queue_rows=15,
        debug_mode=True,
        step_snapshot_limit_gauges=True,
    )

    return event_log_df, full_patient_df, full_patient_df_plus_pos, event_position_df

Scenario 1 - As-is

event_log_df, full_patient_df, full_patient_df_plus_pos, event_position_df = (
    generate_scenario_results("as-is")
)
Placement dataframe started construction at 13:43:45
Placement dataframe finished construction at 13:43:45
/tmp/ipykernel_4131/2772590182.py:85: UserWarning: 8467 row(s) across 1 event(s) with no matching `event_position_df` row were rendered anyway: 'depart' (4667 entities).

An event with no position gets no coordinates, and a point with no coordinates can't be drawn - the entity's icon disappears for that frame, then flies in from the top-left corner once a positioned event takes over again.

Add a row to `event_position_df` for each event listed above.
  full_patient_df_plus_pos = generate_animation_df(
full_patient_df_plus_pos
index patient pathway event_type event home_clinic time booked_clinic wait event_original ... snapshot_time hidden_run_before y_final x label x_final row icon _phantom opacity
56222 16860 9084207 1 queue appointment_booked_waiting_7 7 82 7.0 NaN appointment_booked_waiting ... 82 0 340.0 625.0 Booked into<br>clinic 7 437.5 2.0 [░░░░░░░░░░] + 0 more False 1.0
56223 16860 9084207 1 queue appointment_booked_waiting_7 7 82 7.0 NaN appointment_booked_waiting ... 83 0 340.0 625.0 Booked into<br>clinic 7 437.5 2.0 [░░░░░░░░░░] + 1 more False 1.0
56224 17205 9084207 1 queue appointment_booked_waiting_7 7 84 7.0 NaN appointment_booked_waiting ... 84 0 340.0 625.0 Booked into<br>clinic 7 437.5 2.0 [░░░░░░░░░░] + 0 more False 1.0
56225 17900 9084207 1 queue appointment_booked_waiting_7 7 87 7.0 NaN appointment_booked_waiting ... 87 0 340.0 625.0 Booked into<br>clinic 7 437.5 2.0 [░░░░░░░░░░] + 1 more False 1.0
56226 18110 9084207 1 queue appointment_booked_waiting_7 7 88 7.0 NaN appointment_booked_waiting ... 88 0 340.0 625.0 Booked into<br>clinic 7 437.5 2.0 [░░░░░░░░░░] + 1 more False 1.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
4311 20266 99_9 1 queue appointment_booked_waiting_0 0 99 0.0 NaN appointment_booked_waiting ... 146 0 870.0 625.0 Booked into<br>clinic 0 460.0 0.0 🤰 False 1.0
4358 20266 99_9 1 queue appointment_booked_waiting_0 0 99 0.0 NaN appointment_booked_waiting ... 147 0 870.0 625.0 Booked into<br>clinic 0 505.0 0.0 🤰 False 1.0
4405 20266 99_9 1 queue appointment_booked_waiting_0 0 99 0.0 NaN appointment_booked_waiting ... 148 0 870.0 625.0 Booked into<br>clinic 0 550.0 0.0 🤰 False 1.0
4452 20266 99_9 1 queue appointment_booked_waiting_0 0 99 0.0 NaN appointment_booked_waiting ... 149 0 870.0 625.0 Booked into<br>clinic 0 595.0 0.0 🤰 False 1.0
4502 20266 99_9 1 queue appointment_booked_waiting_0 0 99 0.0 NaN appointment_booked_waiting ... 150 0 870.0 625.0 Booked into<br>clinic 0 595.0 0.0 🤰 False 1.0

56596 rows × 22 columns

event_position_df.head()
event y x label
0 appointment_booked_waiting_0 870.0 625 Booked into<br>clinic 0
1 appointment_booked_waiting_1 790.0 625 Booked into<br>clinic 1
2 appointment_booked_waiting_2 710.0 625 Booked into<br>clinic 2
3 appointment_booked_waiting_3 630.0 625 Booked into<br>clinic 3
4 appointment_booked_waiting_4 550.0 625 Booked into<br>clinic 4
def generate_clinic_animation(final_df):
    fig = generate_animation(
        full_entity_df_plus_pos=final_df,
        event_position_df=event_position_df,
        scenario=None,
        entity_col_name="patient",
        plotly_height=900,
        plotly_width=1000,
        override_x_max=1000,
        override_y_max=1000,
        entity_icon_size=10,
        text_size=10,
        include_play_button=True,
        add_background_image=None,
        display_stage_labels=True,
        time_display_units="d",
        simulation_time_unit="days",
        start_date="2022-06-27",
        setup_mode=False,
        frame_duration=1500,  # milliseconds
        frame_transition_duration=1000,  # milliseconds
        debug_mode=False,
    )

    return fig

    # TODO
    # Add in additional trace that shows the number of available slots per day
    # using the slot df

    # TODO
    # Pooled booking version where being in non-home clinic makes you one colour
    # and home clinic makes you another

    # TODO
    # Investigate adding a priority attribute to event log
    # that can be considered when ranking queues if present
generate_clinic_animation(full_patient_df_plus_pos).show()

With Pooling

event_log_df, full_patient_df, full_patient_df_plus_pos, event_position_df = (
    generate_scenario_results("pooled")
)
Placement dataframe started construction at 13:43:52
Placement dataframe finished construction at 13:43:52
/tmp/ipykernel_4131/2772590182.py:85: UserWarning: 8499 row(s) across 1 event(s) with no matching `event_position_df` row were rendered anyway: 'depart' (4697 entities).

An event with no position gets no coordinates, and a point with no coordinates can't be drawn - the entity's icon disappears for that frame, then flies in from the top-left corner once a positioned event takes over again.

Add a row to `event_position_df` for each event listed above.
  full_patient_df_plus_pos = generate_animation_df(
full_patient_df_plus_pos
index patient pathway event_type event home_clinic time booked_clinic wait event_original ... snapshot_time hidden_run_before y_final x label x_final row icon _phantom opacity
58066 12411 9084207 1 queue appointment_booked_waiting_7 7 60 7.0 NaN appointment_booked_waiting ... 60 0 340.0 625.0 Booked into<br>clinic 7 ⚪ 437.5 2.0 [░░░░░░░░░░] + 0 more False 1.0
58067 12411 9084207 1 queue appointment_booked_waiting_7 7 60 7.0 NaN appointment_booked_waiting ... 61 0 340.0 625.0 Booked into<br>clinic 7 ⚪ 437.5 2.0 [░░░░░░░░░░] + 3 more False 1.0
58068 12411 9084207 1 queue appointment_booked_waiting_7 7 60 7.0 NaN appointment_booked_waiting ... 62 0 340.0 625.0 Booked into<br>clinic 7 ⚪ 437.5 2.0 [░░░░░░░░░░] + 5 more False 1.0
58069 12544 9084207 1 queue appointment_booked_waiting_7 8 61 7.0 NaN appointment_booked_waiting ... 63 0 340.0 625.0 Booked into<br>clinic 7 ⚪ 437.5 2.0 [░░░░░░░░░░] + 4 more False 1.0
58070 12887 9084207 1 queue appointment_booked_waiting_7 8 63 7.0 NaN appointment_booked_waiting ... 64 0 340.0 625.0 Booked into<br>clinic 7 ⚪ 437.5 2.0 [░░░░░░░░░░] + 4 more False 1.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
13081 20364 99_9 1 queue appointment_booked_waiting_2 0 99 2.0 NaN appointment_booked_waiting ... 139 0 710.0 625.0 Booked into<br>clinic 2 🟢 475.0 0.0 👱🏻‍♀️ False 1.0
13125 20364 99_9 1 queue appointment_booked_waiting_2 0 99 2.0 NaN appointment_booked_waiting ... 140 0 710.0 625.0 Booked into<br>clinic 2 🟢 565.0 0.0 👱🏻‍♀️ False 1.0
45301 29007 99_9 1 queue have_appointment_2 0 141 2.0 42.0 have_appointment ... 141 0 710.0 850.0 Attending appointment<br>at clinic 2 790.0 0.0 👱🏻‍♀️ False 1.0
57217 29008 99_9 1 arrival_departure depart 0 142 NaN NaN depart ... 142 0 NaN NaN NaN NaN NaN 👱🏻‍♀️ False 1.0
53450 29008 99_9 1 exit depart 0 142 NaN NaN depart ... 143 0 NaN NaN NaN NaN NaN 👱🏻‍♀️ False 1.0

58466 rows × 22 columns

full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
    icon=full_patient_df_plus_pos.apply(show_home_clinic, axis=1)
)

full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
    icon=full_patient_df_plus_pos.apply(show_priority_icon, axis=1)
)

full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
    icon=full_patient_df_plus_pos.apply(add_los_to_icon, axis=1)
)
generate_clinic_animation(full_patient_df_plus_pos).show()

With Pooling - No Carve-out

event_log_df, full_patient_df, full_patient_df_plus_pos, event_position_df = (
    generate_scenario_results("no_carve_out")
)
Placement dataframe started construction at 13:44:00
Placement dataframe finished construction at 13:44:01
/tmp/ipykernel_4131/2772590182.py:85: UserWarning: 8499 row(s) across 1 event(s) with no matching `event_position_df` row were rendered anyway: 'depart' (4697 entities).

An event with no position gets no coordinates, and a point with no coordinates can't be drawn - the entity's icon disappears for that frame, then flies in from the top-left corner once a positioned event takes over again.

Add a row to `event_position_df` for each event listed above.
  full_patient_df_plus_pos = generate_animation_df(
full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
    icon=full_patient_df_plus_pos.apply(show_home_clinic, axis=1)
)

full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
    icon=full_patient_df_plus_pos.apply(show_priority_icon, axis=1)
)

full_patient_df_plus_pos = full_patient_df_plus_pos.assign(
    icon=full_patient_df_plus_pos.apply(add_los_to_icon, axis=1)
)
generate_clinic_animation(full_patient_df_plus_pos).show()
Back to top