File size: 9,952 Bytes
324d361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
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}<br>Value %{y}<extra></extra>",
                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