diff --git a/src/aind_dynamic_foraging_basic_analysis/plot/plot_foraging_session_plotly.py b/src/aind_dynamic_foraging_basic_analysis/plot/plot_foraging_session_plotly.py index 52acfab..23b689d 100644 --- a/src/aind_dynamic_foraging_basic_analysis/plot/plot_foraging_session_plotly.py +++ b/src/aind_dynamic_foraging_basic_analysis/plot/plot_foraging_session_plotly.py @@ -942,34 +942,123 @@ def _trial_of(times, _gc=gc, _n=n_tr): y_main_top = params["curve_top"] # FIP channels (single-session only), normalised and stacked above the behavior panel + fip_unclipped_ids = [] + fip_clipped_ids = [] if df_fip is not None and len(sessions) == 1 and len(fip) > 0: fip_channels = fip present = set(df_fip["event"].unique()) - band = 0 + band = 0.0 + fip_gap = 0.5 for channel in fip_channels: if channel not in present: continue - bottom = params["curve_top"] + 0.1 + band C = df_fip.query("event == @channel").copy() - d = C["data"].values - np.nanmin(C["data"].values) - d = d / np.nanmax(d) + bottom + vals = C["data"].astype(float).to_numpy() + if vals.size == 0 or np.all(np.isnan(vals)): + continue + vmin = np.nanmin(vals) + vmax = np.nanmax(vals) + span = vmax - vmin + # avoid zero-span stacking problems for constant signals + if np.isnan(span) or span == 0: + span = 1.0 + + # place this channel so its minimum maps to `base`, preserving original scale + base = params["curve_top"] + 0.1 + band + offset = base - vmin + d = vals + offset + color = get_fip_color(channel) + custom = np.stack([C.timestamps.values, vals], axis=-1) + hover = f"%{{customdata[0]:.2f}}s %{{customdata[1]:.3f}} {channel}" + + fip_unclipped_ids.append(len(fig.data)) fig.add_trace( go.Scattergl( x=C.timestamps.values + last_off, y=d, + customdata=custom, + mode="lines", + hovertemplate=hover, + line=dict(color=color), + name=channel, + ), + row=1, + col=1, + ) + + # clipped version: clip raw values to 1st–99th percentile, same y-offset + p01, p99 = np.nanpercentile(vals, 1), np.nanpercentile(vals, 99) + vals_clipped = np.clip(vals, p01, p99) + d_clipped = vals_clipped + offset + fip_clipped_ids.append(len(fig.data)) + fig.add_trace( + go.Scattergl( + x=C.timestamps.values + last_off, + y=d_clipped, + customdata=custom, mode="lines", + hovertemplate=hover, line=dict(color=color), name=channel, + visible=False, + showlegend=False, ), row=1, col=1, ) - yticks.append(bottom + 0.5) - ylabels.append(channel) - band += 1 - y_main_top = bottom + 1.0 + # use three ticks: bottom (vmin), center (channel name), top (vmax) + yticks.extend([base - vmin, base + span / 2.0, base + span]) + ylabels.extend( + [ + "0.0", + f"{channel.split('_dff')[0]}      ", + f"{vmax:.2f}", + ] + ) + + # advance band by the display span (not raw span) plus a small gap to avoid overlap + band += span + fip_gap + y_main_top = base + span + 0.25 + + # Toggle button to switch FIP traces between full range and 1–99% clipped + if fip_unclipped_ids: + n_all = len(fig.data) + vis_full = [True] * n_all + vis_clip = [True] * n_all + for idx in fip_clipped_ids: + vis_full[idx] = False + for idx in fip_unclipped_ids: + vis_clip[idx] = False + for idx in fip_clipped_ids: + vis_clip[idx] = True + + fig.update_layout( + updatemenus=[ + dict( + type="buttons", + direction="right", + x=1.0, + y=1.0, + xanchor="right", + yanchor="top", + showactive=True, + buttons=[ + dict( + label="FIP: full range", + method="restyle", + args=[{"visible": vis_full}], + ), + dict( + label="FIP: clip 1–99%", + method="restyle", + args=[{"visible": vis_clip}], + ), + ], + ) + ] + ) # Thick vertical lines marking session boundaries (both panels) for b in boundaries: for row in (1, 2): @@ -1012,7 +1101,7 @@ def _trial_of(times, _gc=gc, _n=n_tr): # Title pinned to the very top-left so it clears the legend below it. title=dict(text=title or "Session Scroller", x=0.0, xanchor="left", y=0.98, yanchor="top"), showlegend=True, - height=620, + height=620 + (50 * len(fip) if df_fip is not None and len(fip) > 0 else 0), width=1000, template="simple_white", # Legend outside, top-left, horizontal, compact entries (narrow box).