import time
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from examples.example_13_additional_synchronised_traces_method_1.simulation_execution_functions import multiple_replications
from examples.example_13_additional_synchronised_traces_method_1.model_classes import Scenario, Schedule
from vidigi.prep import reshape_for_animations, generate_animation_df
from vidigi.animation import generate_animation
from plotly.subplots import make_subplots
import plotly.io as pio
= "notebook"
pio.renderers.default
= True
TRACE =True
debug_mode
= Schedule() schedule
Additional Synchronised Traces - Orthopaedic Ward - Hospital Efficiency Project
This is the orthopaedic surgery model developed as part of the hospital efficiency project.
original model author = Harper, Alison and Monks, Thomas
license = MIT
title = Hospital Efficiency Project Orthopaedic Planning Model Discrete-Event Simulation
url = https://github.com/AliHarp/HEP
It has been used as a test case here to allow the development and testing of several key features of the event log animations:
adding of logging to a model from scratch
ensuring the requirement to use simpy stores instead of simpy resources doesn’t prevent the uses of certain common modelling patterns (in this case, conditional logic where patients will leave the system if a bed is not available within a specified period of time)
displaying different icons for different classes of patients
displaying custom resource icons
displaying additional static information as part of the icon (in this case, whether the client’s discharge is delayed)
displaying information that updates with each animation step as part of the icon (in this case, the LoS of the patient at each time point)
4 theatres
5 day/week
Each theatre has three sessions per day:
Morning: 1 revision OR 2 primary
Afternoon: 1 revision OR 2 primary
Evening: 1 primary
40 ring-fenced beds for recovery from these operations
="index")
(pd.DataFrame.from_dict(schedule.sessions_per_weekday, orient={0: "Sessions"}).merge(
.rename(columns
="index")
pd.DataFrame.from_dict(schedule.theatres_per_weekday, orient={0: "Theatre Capacity"}),
.rename(columns=True, right_index=True
left_index
).merge(
="index"),
pd.DataFrame.from_dict(schedule.allocation, orient=True, right_index=True
left_index
))
Sessions | Theatre Capacity | 0 | 1 | 2 | |
---|---|---|---|---|---|
Monday | 3 | 4 | 2P_or_1R | 2P_or_1R | 1P |
Tuesday | 3 | 4 | 2P_or_1R | 2P_or_1R | 1P |
Wednesday | 3 | 4 | 2P_or_1R | 2P_or_1R | 1P |
Thursday | 3 | 4 | 2P_or_1R | 2P_or_1R | 1P |
Friday | 3 | 4 | 2P_or_1R | 2P_or_1R | 1P |
Saturday | 0 | 0 | None | None | None |
Sunday | 0 | 0 | None | None | None |
= 35
n_beds
= 4.4
primary_hip_los
= 4.7
primary_knee_los
= 6.9
revision_hip_los
= 7.2
revision_knee_los
= 2.9
unicompart_knee_los
= 16.5
los_delay = 15.2
los_delay_sd
= 0.076
prop_delay
= 30
replications = 60
runtime =7
warmup
= Scenario(schedule=schedule,
args =primary_hip_los,
primary_hip_mean_los=primary_knee_los,
primary_knee_mean_los=revision_hip_los,
revision_hip_mean_los=revision_knee_los,
revision_knee_mean_los=unicompart_knee_los,
unicompart_knee_mean_los=prop_delay,
prob_ward_delay=n_beds,
n_beds=los_delay,
delay_post_los_mean=los_delay_sd
delay_post_los_sd
)
= multiple_replications(
results =True,
return_detailed_logs=args,
scenario=replications,
n_reps=runtime
results_collection
)
# Join the event log with a list of patients to add a column that will determine
# the icon set used for a patient (in this case, we want to distinguish between the
# knee/hip patients)
= results[4]
event_log = event_log[event_log['rep'] == 1].copy()
event_log 'patient'] = event_log['patient'].astype('str') + event_log['pathway']
event_log[
= results[2]
primary_patients = primary_patients[primary_patients['rep'] == 1]
primary_patients 'patient class'] = primary_patients['patient class'].str.title()
primary_patients['ID'] = primary_patients['ID'].astype('str') + primary_patients['patient class']
primary_patients[
= results[3]
revision_patients = revision_patients[revision_patients['rep'] == 1]
revision_patients 'patient class'] = revision_patients['patient class'].str.title()
revision_patients['ID'] = revision_patients['ID'].astype('str') + revision_patients['patient class']
revision_patients[
= event_log.merge(pd.concat([primary_patients, revision_patients]),
full_log_with_patient_details ="left",
how=["patient", "pathway"],
left_on=["ID", "patient class"]).reset_index(drop=True).drop(columns="ID")
right_on
= full_log_with_patient_details[['patient']].drop_duplicates().reset_index(drop=True).reset_index(drop=False).rename(columns={'index': 'pid'})
pid_table
= full_log_with_patient_details.merge(pid_table, how='left', on='patient').drop(columns='patient').rename(columns={'pid':'patient'}) full_log_with_patient_details
C:\Users\Sammi\AppData\Local\Temp\ipykernel_49316\503693668.py:54: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
C:\Users\Sammi\AppData\Local\Temp\ipykernel_49316\503693668.py:55: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
C:\Users\Sammi\AppData\Local\Temp\ipykernel_49316\503693668.py:59: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
C:\Users\Sammi\AppData\Local\Temp\ipykernel_49316\503693668.py:60: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
= pd.DataFrame([
event_position_df # {'event': 'arrival', 'x': 10, 'y': 250, 'label': "Arrival" },
# Triage - minor and trauma
'event': 'enter_queue_for_bed',
{'x': 300, 'y': 600, 'label': "Waiting for<br>Availability of<br>Bed to be Confirmed<br>Before Surgery" },
'event': 'no_bed_available',
{'x': 800, 'y': 600, 'label': "No Bed<br>Available:<br>Surgery Cancelled" },
'event': 'post_surgery_stay_begins',
{'x': 850, 'y': 220, 'resource':'n_beds', 'label': "In Bed:<br>Recovering from<br>Surgery" },
'event': 'discharged_after_stay',
{'x': 770, 'y': 50, 'label': "Discharged from Hospital<br>After Recovery"}
# {'event': 'exit',
# 'x': 670, 'y': 100, 'label': "Exit"}
])
= reshape_for_animations(full_log_with_patient_details,
full_patient_df ="patient",
entity_col_name=1,
every_x_time_units=runtime,
limit_duration=50,
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())}')
Iteration through time-unit-by-time-unit logs complete 12:30:40
Snapshot df concatenation complete at 12:30:40
Reshaped animation dataframe finished construction at 12:30:41
= generate_animation_df(
full_patient_df_plus_pos =full_patient_df,
full_entity_df="patient",
entity_col_name=event_position_df,
event_position_df=20,
wrap_queues_at=40,
wrap_resources_at=50,
step_snapshot_max=20,
gap_between_entities=20,
gap_between_resources=175,
gap_between_queue_rows=175,
gap_between_resource_rows=debug_mode
debug_mode )
Placement dataframe finished construction at 12:30:41
def set_icon(row):
if row["surgery type"] == "p_knee":
return "🦵<br>1️⃣<br> "
elif row["surgery type"] == "r_knee":
return "🦵<br>♻️<br> "
elif row["surgery type"] == "p_hip":
return "🕺<br>1️⃣<br> "
elif row["surgery type"] == "r_hip":
return "🕺<br>♻️<br> "
elif row["surgery type"] == "uni_knee":
return "🦵<br>✳️<br> "
else:
return f"CHECK<br>{row['icon']}"
= full_patient_df_plus_pos.assign(icon=full_patient_df_plus_pos.apply(set_icon, axis=1))
full_patient_df_plus_pos
# TODO: Check why this doesn't seem to be working quite right for the 'discharged after stay'
# step. e.g. 194Primary is discharged on 28th July showing a LOS of 1 but prior to this shows a LOS of 9.
def add_los_to_icon(row):
if row["event"] == "post_surgery_stay_begins":
return f'{row["icon"]}<br><br>{row["snapshot_time"]-row["time"]:.0f}'
elif row["event"] == "discharged_after_stay":
return f'{row["icon"]}<br>{row["los"]:.0f}'
else:
return row["icon"]
= full_patient_df_plus_pos.assign(icon=full_patient_df_plus_pos.apply(add_los_to_icon, axis=1))
full_patient_df_plus_pos
def indicate_delay_via_icon(row):
if row["delayed discharge"] is True:
return f'{row["icon"]}<br>*'
else:
return f'{row["icon"]}<br> '
= full_patient_df_plus_pos.assign(icon=full_patient_df_plus_pos.apply(indicate_delay_via_icon, axis=1))
full_patient_df_plus_pos
= len(full_log_with_patient_details[full_log_with_patient_details['event'] == "no_bed_available"]["patient"].unique())
cancelled_due_to_no_bed_available = len(full_log_with_patient_details["patient"].unique())
total_patients
= cancelled_due_to_no_bed_available/total_patients
cancelled_perc
# st.markdown(f"Surgeries cancelled due to no bed being available in time: {cancelled_perc:.2%} ({cancelled_due_to_no_bed_available} of {total_patients})")
# st.markdown(
# """
# **Key**:
# 🦵1️⃣: Primary Knee
# 🦵♻️: Revision Knee
# 🕺1️⃣: Primary Hip
# 🕺♻️: Revision Hip
# 🦵✳️: Primary Unicompartment Knee
# An asterisk (*) indicates that the patient has a delayed discharge from the ward.
# The numbers below patients indicate their length of stay.
# Note that the "No Bed Available: Surgery Cancelled" and "Discharged from Hospital after Recovery" stages in the animation are lagged by one day.
# For example, on the 2nd of July, this will show the patients who had their surgery cancelled on 1st July or were discharged on 1st July.
# These steps are included to make it easier to understand the destinations of different clients, but due to the size of the simulation step shown (1 day) it is difficult to demonstrate this differently.
# """
# )
= full_patient_df_plus_pos[full_patient_df_plus_pos['event']=='no_bed_available'][['snapshot_time','patient']].groupby('snapshot_time').agg('count')
counts_not_avail = counts_not_avail.reset_index().merge(full_patient_df_plus_pos[['snapshot_time']].drop_duplicates(), how='right').sort_values('snapshot_time')
counts_not_avail 'patient'] = counts_not_avail['patient'].fillna(0)
counts_not_avail['running_total'] = counts_not_avail['patient'].cumsum()
counts_not_avail[
= counts_not_avail.reset_index(drop=True)
counts_not_avail
counts_not_avail
snapshot_time | patient | running_total | |
---|---|---|---|
0 | 0 | 0.0 | 0.0 |
1 | 1 | 0.0 | 0.0 |
2 | 2 | 0.0 | 0.0 |
3 | 3 | 8.0 | 8.0 |
4 | 4 | 12.0 | 20.0 |
... | ... | ... | ... |
56 | 56 | 0.0 | 305.0 |
57 | 57 | 0.0 | 305.0 |
58 | 58 | 8.0 | 313.0 |
59 | 59 | 12.0 | 325.0 |
60 | 60 | 10.0 | 335.0 |
61 rows × 3 columns
= full_patient_df_plus_pos[full_patient_df_plus_pos['event']=='post_surgery_stay_begins'][['snapshot_time','patient']].drop_duplicates('patient').groupby('snapshot_time').agg('count')
counts_ops_completed = counts_ops_completed.reset_index().merge(full_patient_df_plus_pos[['snapshot_time']].drop_duplicates(), how='right').sort_values('snapshot_time')
counts_ops_completed 'patient'] = counts_ops_completed['patient'].fillna(0)
counts_ops_completed['running_total'] = counts_ops_completed['patient'].cumsum()
counts_ops_completed[
= counts_ops_completed.reset_index(drop=True)
counts_ops_completed
counts_ops_completed
snapshot_time | patient | running_total | |
---|---|---|---|
0 | 0 | 15.0 | 15.0 |
1 | 1 | 17.0 | 32.0 |
2 | 2 | 5.0 | 37.0 |
3 | 3 | 3.0 | 40.0 |
4 | 4 | 5.0 | 45.0 |
... | ... | ... | ... |
56 | 56 | 12.0 | 359.0 |
57 | 57 | 4.0 | 363.0 |
58 | 58 | 6.0 | 369.0 |
59 | 59 | 4.0 | 373.0 |
60 | 60 | 7.0 | 380.0 |
61 rows × 3 columns
= counts_not_avail.merge(counts_ops_completed.rename(columns={'running_total':'completed'}), how="left", on="snapshot_time")
counts_not_avail 'perc_slots_lost'] = counts_not_avail['running_total'] / (counts_not_avail['running_total'] + counts_not_avail['completed'])
counts_not_avail[
= counts_not_avail.reset_index(drop=True)
counts_not_avail
counts_not_avail
snapshot_time | patient_x | running_total | patient_y | completed | perc_slots_lost | |
---|---|---|---|---|---|---|
0 | 0 | 0.0 | 0.0 | 15.0 | 15.0 | 0.000000 |
1 | 1 | 0.0 | 0.0 | 17.0 | 32.0 | 0.000000 |
2 | 2 | 0.0 | 0.0 | 5.0 | 37.0 | 0.000000 |
3 | 3 | 8.0 | 8.0 | 3.0 | 40.0 | 0.166667 |
4 | 4 | 12.0 | 20.0 | 5.0 | 45.0 | 0.307692 |
... | ... | ... | ... | ... | ... | ... |
56 | 56 | 0.0 | 305.0 | 12.0 | 359.0 | 0.459337 |
57 | 57 | 0.0 | 305.0 | 4.0 | 363.0 | 0.456587 |
58 | 58 | 8.0 | 313.0 | 6.0 | 369.0 | 0.458944 |
59 | 59 | 12.0 | 325.0 | 4.0 | 373.0 | 0.465616 |
60 | 60 | 10.0 | 335.0 | 7.0 | 380.0 | 0.468531 |
61 rows × 6 columns
= generate_animation(
fig =full_patient_df_plus_pos,
full_entity_df_plus_pos="patient",
entity_col_name=event_position_df,
event_position_df=args,
scenario=750,
plotly_height=1000,
plotly_width=1000,
override_x_max=700,
override_y_max=11,
entity_icon_size=13,
resource_icon_size=14,
text_size=40,
wrap_resources_at=20,
gap_between_resources=True,
include_play_button=None,
add_background_image# we want the stage labels, but due to a bug
# when we add in additional animated traces later,
# they will disappear - so better to leave them out here
# and then re-add them manually
=True,
display_stage_labels="🛏️",
custom_resource_icon="d",
time_display_units="days",
simulation_time_unit="2022-06-27",
start_date=False,
setup_mode=1500, #milliseconds
frame_duration=1000, #milliseconds
frame_transition_duration=False
debug_mode
)
fig
# Set up the desired subplot layout
= 4
ROWS
= make_subplots(
sp =ROWS,
rows=1,
cols=[0.75, 0.05, 0.05, 0.15],
row_heights=0.05,
vertical_spacing=(
subplot_titles"", # Original Animation
"", # Completed Operations
"", # Lost Slot Cumulative Counts
"" # Daily Lost Slots Plot
)
)
# Overwrite the domain of our original x and y axis with domain from the new axis
'xaxis']['domain'] = sp.layout['xaxis']['domain']
fig.layout['yaxis']['domain'] = sp.layout['yaxis']['domain']
fig.layout[
for i in range(2, ROWS+1):
# Add in the attributes for the secondary axis from our subplot
f'xaxis{i}'] = sp.layout[f'xaxis{i}']
fig.layout[f'yaxis{i}'] = sp.layout[f'yaxis{i}']
fig.layout[
# Final key step - copy over the _grid_ref attribute
# This isn't meant to be something we modify but it's an essential
# part of the subplot code because otherwise plotly doesn't truly know
# how the different subplots are arranged and referenced
= sp._grid_ref
fig._grid_ref
fig.update_layout(=dict(
xaxis2=False,
showgrid=False,
zeroline=False,
showline=False,
showticklabels
),=dict(
yaxis2=False,
showgrid=False,
zeroline=False,
showline=False,
showticklabels
),=dict(
xaxis3=False,
showgrid=False,
zeroline=False,
showline=False,
showticklabels
),=dict(
yaxis3=False,
showgrid=False,
zeroline=False,
showline=False,
showticklabels
),=dict(
xaxis4=False
showticklabels
) )
print(len(fig.data))
3
#####################################################
# Adding additional animation traces
#####################################################
#####################################################
# Initialize static and animated traces
#####################################################
## First, add each trace so it will show up initially
# Plotly requires that all traces that will appear in animation frames are first
# defined in `fig.data`. Otherwise, they appear to "fly in" from undefined positions,
# or exhibit flickering due to missing interpolation references.
# We add each trace in order, with placeholder data and correct styling,
# so the animation engine has full knowledge of the traces from the outset.
# Due to issues detailed in the following SO threads, it's essential to initialize the traces
# outside of the frames argument else they will not show up at all (or show up intermittently)
# https://stackoverflow.com/questions/69867334/multiple-traces-per-animation-frame-in-plotly
# https://stackoverflow.com/questions/69367344/plotly-animating-a-variable-number-of-traces-in-each-frame-in-r
# TODO: More explanation and investigation needed of why sometimes traces do and don't show up after being added in
# via this method. Behaviour seems very inconsistent and not always logical (e.g. order you put traces in to the later
# loop sometimes seems to make a difference but sometimes doesn't; making initial trace transparent sometimes seems to
# stop it showing up when added in the frames but not always; sometimes the initial trace doesn't disappear).
# First, extract the trace containing the resource icons
= fig.data[1]
position_label_trace = fig.data[2]
icon_trace
# Now keep our figure data as just the initial trace.
= (fig.data[0],)
fig.data
# 1. BED ICONS TRACE
# Readd the bed icons trace in a consistent manner
# Confusingly, when we start messing with the naimation frames, we lose the bed/resource icon trace
# even though it appeared fine until this point - so we have to handle it here
fig.add_trace(icon_trace)
print(f"Length after adding bed trace: {len(fig.data)}")
# 2. EVENT LABELS (static position text, but added dynamically per frame to avoid disappearing)
# This is a similar thing to the fig labels - except we never added them in the first place!
# Add trace for the event labels (as these get lost from the animation once we start trying to add other things in,
# so need manually re-adding)
# fig.add_trace(go.Scatter(
# x=[pos+10 for pos in event_position_df['x'].to_list()],
# y=event_position_df['y'].to_list(),
# mode="text",
# name="",
# text=event_position_df['label'].to_list(),
# textposition="middle right",
# hoverinfo='none'
# ))
fig.add_trace(position_label_trace)
# Finally, match the font size for the position labels
-1].textfont.size
fig.data[
print(f"Length after adding 'position labels:' trace: {len(fig.data)}")
# 3. OPERATIONS COMPLETED TEXT (animated text annotation)
# Add animated text trace that gives running total of operations completed
fig.add_trace(go.Scatter(=[5],
x=[10],
y# text="",
=f"Operations Completed: {int(counts_ops_completed['running_total'][0])}",
text='text',
mode="middle left",
textposition=dict(size=20),
textfont# opacity=0,
=False,
showlegend="x2",
xaxis="y2"
yaxis=2, col=1)
), row
print(f"Length after adding 'operations completed:' trace: {len(fig.data)}")
# 4. SLOTS LOST TEXT (animated text annotation)
# Add animated trace giving running total of slots lost and percentage of total slots this represents
fig.add_trace(go.Scatter(=[5],
x=[10],
y# text="",
=f"Total slots lost: {int(counts_not_avail['running_total'][0])} ({counts_not_avail['perc_slots_lost'][0]:.1%})",
text='text',
mode=dict(size=20),
textfont# opacity=0,
=False,
showlegend="middle left",
textposition="x3",
xaxis="y3"
yaxis=3, col=1)
), row
print(f"Length after adding 'slots lost:' trace: {len(fig.data)}")
# # 5. LINE PLOT ON SECONDARY AXIS (animated line in subplot)
# Initialize with a single point and assign it to subplot axes (x2/y2)
fig.add_trace(go.Scatter(=[counts_not_avail['snapshot_time'].iloc[0]],
x=[counts_not_avail['patient_x'].iloc[0]],
y="lines",
mode=dict(color="rgba(255,0,0,1)"), # semi-transparent initial
line=False,
showlegend="slots_lost_line",
name="x4",
xaxis="y4"
yaxis# We place it in our new subplot using the following line
=4, col=1)
), row
# Add an initial trace to our secondary line chart
fig.add_trace(go.Scatter(=counts_not_avail['snapshot_time'],
x=counts_not_avail['patient_x'],
y='lines',
mode=False,
showlegend# name='line',
=0.2,
opacity="x4",
xaxis="y4"
yaxis# We place it in our new subplot using the following line
=4, col=1)
), row
print(f"Length after adding additional line plot trace: {len(fig.data)}")
Length after adding bed trace: 2
Length after adding 'position labels:' trace: 3
Length after adding 'operations completed:' trace: 4
Length after adding 'slots lost:' trace: 5
Length after adding additional line plot trace: 7
##########################################################
# Define animation frames: one per simulation time step
##########################################################
##########################################################
# Now we need to add our traces to each individual frame
##########################################################
# IMPORTANT: To work correctly, these need to be provided in the same order as the traces above
# This includes:
# 0: bed icons
# 1: event labels
# 2: operations completed text
# 3: slots lost text
# 4: time series line in subplot
# # Now ensure we tell it which traces we are animating
# # (as per https://chart-studio.plotly.com/~empet/15243/animating-traces-in-subplotsbr/#/)
for i, frame in enumerate(fig.frames):
# Your original frame.data
# This will be a tuple
# We'll ensure we only take the first entry
# original_data = (frame.data[0], )
= frame.data
original_data
# if i == 5:
# print(original_data)
# The new data you want to add for this specific frame
= (
new_data # 0: bed icons
icon_trace,
# 1: Position labels
go.Scatter(=[pos+10 for pos in event_position_df['x'].to_list()],
x=event_position_df['y'].to_list(),
y="text",
mode=event_position_df['label'].to_list(),
text="middle right",
textposition='none',
hoverinfo=False,
showlegend
),
# 2: Slots used/operations occurred
go.Scatter(=[5],
x=[10],
y=f"Operations Completed: {int(counts_ops_completed.sort_values('snapshot_time')['running_total'][i])}",
text='text',
mode="middle left",
textposition=dict(size=20),
textfont=False,
showlegend='x2',
xaxis='y2'
yaxis
),
# 3: Slots lost
go.Scatter(=[5],
x=[10],
y=f"Total slots lost: {int(counts_not_avail.sort_values('snapshot_time')['running_total'][i])} ({counts_not_avail.sort_values('snapshot_time')['perc_slots_lost'][i]:.1%})",
text='text',
mode=dict(size=20),
textfont="middle left",
textposition=False,
showlegend='x3',
xaxis='y3'
yaxis
),
# 4: Line subplot
go.Scatter(=counts_not_avail.sort_values('snapshot_time')['snapshot_time'][0: i+1].values,
x=counts_not_avail.sort_values('snapshot_time')['patient_x'][0: i+1].values,
y="lines",
mode=False,
showlegend="line_subplot",
name=dict(color="rgba(255,0,0,1)"), # semi-transparent initial
line='x4',
xaxis='y4'
yaxis
),
)
# print(f"Type of new data: {type(new_data)}")
# Combine the original frame data with your new data
= original_data + new_data
frame.data
# if i == 5:
# print(frame.data)
# print(f"Type of final frame data: {type(frame.data)}")
# Finally, match the font size for the position labels
2].textfont.size
fig.data[
fig
# After modifying the data in all frames, now correctly set the 'traces' property.
# Get the total number of animated traces from the first (now updated) frame.
= len(fig.frames[0].data)
num_total_traces
# Create the list of indices that all traces will be mapped to.
# This should be [0, 1, 2, ..., n-1] where n is the total number of animated traces.
= list(range(num_total_traces))
trace_indices
# Apply this correct list of indices to every frame.
for frame in fig.frames:
= trace_indices frame.traces
fig