import plotly.graph_objects as go
import plotly.express as px
import numpy as np
import pandas as pd
def make_figure(
tidy_df: pd.DataFrame,
stats_df: pd.DataFrame,
analytes: list,
astronaut_filter=None,
show_error: str = None
):
"""
Build interactive mission-day plots with stats overlays.
"""
fig = go.Figure()
# Highlight stretched space interval (0 to 30 days)
fig.add_vrect(x0=0, x1=30, fillcolor="LightGray", opacity=0.3,
layer="below", line_width=0)
for day in [10, 20]:
fig.add_vline(x=day, line=dict(color="white", width=2, dash="dot"),
layer="below")
df = tidy_df.copy()
# Apply participant filter
if astronaut_filter is None:
pass # show all
elif isinstance(astronaut_filter, str) and astronaut_filter in ["Male", "Female"]:
if "sex" in df.columns:
df = df[df["sex"] == astronaut_filter]
elif isinstance(astronaut_filter, (list, tuple, set)):
df = df[df["astronautID"].isin(astronaut_filter)]
# Loop analytes requested
for analyte in analytes:
subdf = df[df["analyte"] == analyte]
if subdf.empty:
print(f"[make_figure] Skipping {analyte} – no data")
continue
## Y-axis scaling
ref_min = subdf["min"].dropna().min()
ref_max = subdf["max"].dropna().max()
data_min = subdf["value"].min()
data_max = subdf["value"].max()
if "unit" in subdf.columns and not subdf["unit"].dropna().empty:
unit = subdf["unit"].dropna().iloc[0]
y_label = f"{analyte.title()} ({unit})"
else:
y_label = analyte.title()
## Add healthy range lines from min / max
if pd.notna(ref_min):
fig.add_hline(
y=ref_min,
line=dict(color="green", width=2, dash="dot"),
annotation_text="Min",
annotation_position="bottom right"
)
if pd.notna(ref_max):
fig.add_hline(
y=ref_max,
line=dict(color="green", width=2, dash="dot"),
annotation_text="Max",
annotation_position="top right"
)
## Decide axis limits: must include BOTH healthy range and all data
low_candidates = [v for v in [ref_min, data_min] if pd.notna(v)]
high_candidates = [v for v in [ref_max, data_max] if pd.notna(v)]
if low_candidates and high_candidates:
low = min(low_candidates)
high = max(high_candidates)
span = high - low if high > low else 1
padding = 0.1 * span
y_range = [low - padding, high + padding]
else:
y_range = None
## Apply axis update once
if y_range:
fig.update_yaxes(title=y_label, range=y_range)
else:
fig.update_yaxes(title=y_label)
## Plot each astronaut trace - first colors
palette = px.colors.qualitative.Set2
astronaut_colors = {astr: palette[i % len(palette)]
for i, astr in enumerate(subdf["astronautID"].unique())}
## Plot each astronaut trace
for astronaut, adf in subdf.groupby("astronautID"):
if adf.empty:
continue
adf = adf.sort_values("flight_day")
base_color = astronaut_colors[astronaut]
### Skip if astronaut not in filter
if isinstance(astronaut_filter, (list, tuple, set)) and astronaut not in astronaut_filter:
continue
# Main Scatter Plot
fig.add_trace(go.Scatter(
x=adf["flight_day"],
y=adf["value"],
mode="lines+markers",
name=f"{astronaut} ({analyte})",
hovertext=adf["timepoint"],
hovertemplate="Day %{hovertext}
Value %{y}",
line=dict(color=base_color),
marker=dict(color=base_color)
))
### Within-astronaut error band
if show_error == "within" and not stats_df.empty:
stat_rows = stats_df[
(stats_df["analyte"] == analyte)
& (stats_df["test_type"] == "within")
]
for _, row in stat_rows.iterrows():
astronaut = row["astronautID"]
if astronaut not in subdf["astronautID"].unique():
continue # skip astronauts not in this analyte subset
mean_L = row.get("mean_L", np.nan)
se = row.get("se_L", np.nan)
R1 = row.get("R1", np.nan)
if pd.isna(mean_L) or pd.isna(se):
continue
base_color = astronaut_colors.get(astronaut, "gray")
if base_color.startswith("rgb"):
fill_color = base_color.replace("rgb", "rgba").replace(")", ",0.15)")
else:
fill_color = base_color
#### Horizontal band: L +/- SE
fig.add_hrect(
y0=mean_L - se, y1=mean_L + se,
fillcolor=fill_color,
opacity=0.2,
line_width=0,
layer="below"
)
#### Asterisk if R+1 outside band
if pd.notna(R1) and (R1 < mean_L - se or R1 > mean_L + se):
fig.add_annotation(
x=31,
y=R1,
text="*",
showarrow=False,
font=dict(size=20, color="red"),
yshift=15
)
## Group-level error band
if show_error == "group" and not stats_df.empty:
stat_rows = stats_df[
(stats_df["analyte"] == analyte)
& (stats_df["test_type"] == "group")
]
for _, row in stat_rows.iterrows():
mean_L = row.get("mean_L", np.nan)
n = row.get("n_L", 0)
error = np.nan
if pd.notna(row.get("effect_size")) and n > 1 and row["effect_size"] != 0:
error = abs(row.get("R1", np.nan) - mean_L) / abs(row["effect_size"])
if pd.isna(error):
error = 0
#### Filter bands only if stats_df has group info
should_plot = True
if "group" in row.index and astronaut_filter is not None:
group_id = row["group"]
if isinstance(astronaut_filter, str) and astronaut_filter in ["Male", "Female"]:
should_plot = (group_id == astronaut_filter)
elif isinstance(astronaut_filter, (list, tuple, set)):
# Only show if group_id matches one of the selected astronauts
should_plot = (group_id in astronaut_filter)
if should_plot and pd.notna(mean_L):
fig.add_hrect(
y0=mean_L - error, y1=mean_L + error,
fillcolor="gray", opacity=0.2,
layer="below", line_width=0,
annotation_text = "Group Error Band",
annotation_position="top left"
)
if row.get("p_value") is not None and row["p_value"] < 0.05:
fig.add_annotation(
x=31, # R+1 = 31
y=row.get("R1", mean_L),
text="*",
showarrow=False,
font=dict(size=20, color="red"),
yshift=15
)
## Only update range if ref_min/ref_max are valid
if pd.notna(ref_min) and pd.notna(ref_max):
fig.update_yaxes(title=y_label,
range=[ref_min * 0.9, ref_max * 1.1])
else:
fig.update_yaxes(title=y_label)
# Layout: Build Dynamic Title
if astronaut_filter is None:
group_label = "All Participants"
elif isinstance(astronaut_filter, str) and astronaut_filter in ["Male", "Female"]:
group_label = f"{astronaut_filter} Participants"
elif isinstance(astronaut_filter, (list, tuple, set)):
group_label = "Subset: " + ", ".join(astronaut_filter)
else:
group_label = "Participants"
# Build analyte label with units if available
ana_label = ", ".join(analytes)
unit_label = ""
subdf = df[df["analyte"] == analytes[0]]
if "unit" in subdf.columns and not subdf["unit"].dropna().empty:
unit_label = f" ({subdf['unit'].dropna().iloc[0]})"
fig.update_layout(
title=f"{ana_label.title()}{unit_label} Trends ({group_label})",
xaxis_title="Mission Day",
legend_title="Participant / Analyte",
hovermode="x unified",
template="plotly_white",
margin=dict(l=60, r=30, t=60, b=60)
)
# Custom ticks
ticks = [t for t in sorted(df["flight_day"].dropna().unique()) if pd.notna(t)]
ticktext = []
for t in ticks:
if t >= 30:
lbl = f"R+{int(t-30)}"
else:
lbl = f"L{int(t)}"
ticktext.append(lbl)
if ticks:
fig.update_xaxes(tickmode="array", tickvals=ticks, ticktext=ticktext)
return fig