Fix flickering, flatten stats, add AI interpretation to all tabs
Browse files- Add .streamlit/config.toml: headless mode, disable file watcher (stops spurious reruns in Docker)
- Guard session_state writes with _clean_key so objects never change between reruns
- Flatten Trend and Stationarity into Summary Statistics (no nested expander)
- Extract reusable _render_ai_interpretation helper
- Add AI Chart Interpretation to Panel and Spaghetti tabs
- Add Per-series Summary table to Spaghetti tab for consistency
- .streamlit/config.toml +7 -0
- app.py +99 -57
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[server]
|
| 2 |
+
headless = true
|
| 3 |
+
fileWatcherType = "none"
|
| 4 |
+
runOnSave = false
|
| 5 |
+
|
| 6 |
+
[browser]
|
| 7 |
+
gatherUsageStats = false
|
app.py
CHANGED
|
@@ -159,7 +159,7 @@ def _render_cleaning_report(report: CleaningReport) -> None:
|
|
| 159 |
|
| 160 |
|
| 161 |
def _render_summary_stats(stats) -> None:
|
| 162 |
-
"""Render SummaryStats as metric cards
|
| 163 |
row1 = st.columns(4)
|
| 164 |
row1[0].metric("Count", f"{stats.count:,}")
|
| 165 |
row1[1].metric("Missing", f"{stats.missing_count} ({stats.missing_pct:.1f}%)")
|
|
@@ -172,32 +172,58 @@ def _render_summary_stats(stats) -> None:
|
|
| 172 |
row2[2].metric("Median", f"{stats.median_val:,.2f}")
|
| 173 |
row2[3].metric("75th %ile / Max", f"{stats.p75:,.2f} / {stats.max_val:,.2f}")
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
st.caption(
|
| 198 |
-
|
| 199 |
-
|
| 200 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
|
| 203 |
# ---------------------------------------------------------------------------
|
|
@@ -217,7 +243,7 @@ style_dict = get_miami_mpl_style()
|
|
| 217 |
for key in [
|
| 218 |
"raw_df", "cleaned_df", "cleaning_report", "freq_info",
|
| 219 |
"date_col", "y_cols", "qc", "qc_hash",
|
| 220 |
-
"_upload_id", "_upload_delim", "
|
| 221 |
]:
|
| 222 |
if key not in st.session_state:
|
| 223 |
st.session_state[key] = None
|
|
@@ -306,16 +332,21 @@ with st.sidebar:
|
|
| 306 |
key="sidebar_missing_action",
|
| 307 |
)
|
| 308 |
|
| 309 |
-
# Clean
|
| 310 |
if y_cols:
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
|
|
|
| 319 |
st.caption(f"Frequency: **{freq_info.label}** "
|
| 320 |
f"({'regular' if freq_info.is_regular else 'irregular'})")
|
| 321 |
|
|
@@ -602,29 +633,9 @@ with tab_single:
|
|
| 602 |
_render_summary_stats(stats)
|
| 603 |
|
| 604 |
# ---- AI Interpretation ------------------------------------------------
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
"Do not include sensitive data in your charts."
|
| 609 |
-
)
|
| 610 |
-
if not check_api_key_available():
|
| 611 |
-
st.warning("Set `OPENAI_API_KEY` to enable AI interpretation.")
|
| 612 |
-
elif fig is not None:
|
| 613 |
-
if st.button("Interpret Chart with AI", key="interpret_a"):
|
| 614 |
-
with st.spinner("Analyzing chart..."):
|
| 615 |
-
png = fig_to_png_bytes(fig)
|
| 616 |
-
date_range_str = (
|
| 617 |
-
f"{df_plot[date_col].min().date()} to "
|
| 618 |
-
f"{df_plot[date_col].max().date()}"
|
| 619 |
-
)
|
| 620 |
-
metadata = {
|
| 621 |
-
"chart_type": chart_type,
|
| 622 |
-
"frequency_label": freq_info.label if freq_info else "Unknown",
|
| 623 |
-
"date_range": date_range_str,
|
| 624 |
-
"y_column": active_y,
|
| 625 |
-
}
|
| 626 |
-
interp = interpret_chart(png, metadata)
|
| 627 |
-
render_interpretation(interp)
|
| 628 |
|
| 629 |
# ===================================================================
|
| 630 |
# Tab B — Few Series (Panel)
|
|
@@ -654,6 +665,7 @@ with tab_few:
|
|
| 654 |
palette_name_b = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_b")
|
| 655 |
palette_b = get_palette_colors(palette_name_b, len(panel_cols))
|
| 656 |
|
|
|
|
| 657 |
try:
|
| 658 |
fig_panel = plot_panel(
|
| 659 |
working_df, date_col, panel_cols,
|
|
@@ -684,6 +696,12 @@ with tab_few:
|
|
| 684 |
use_container_width=True,
|
| 685 |
)
|
| 686 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
# ===================================================================
|
| 688 |
# Tab C — Many Series (Spaghetti)
|
| 689 |
# ===================================================================
|
|
@@ -720,6 +738,7 @@ with tab_many:
|
|
| 720 |
palette_name_c = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_c")
|
| 721 |
palette_c = get_palette_colors(palette_name_c, len(spag_cols))
|
| 722 |
|
|
|
|
| 723 |
try:
|
| 724 |
fig_spag = plot_spaghetti(
|
| 725 |
working_df, date_col, spag_cols,
|
|
@@ -734,3 +753,26 @@ with tab_many:
|
|
| 734 |
st.pyplot(fig_spag, use_container_width=True)
|
| 735 |
except Exception as exc:
|
| 736 |
st.error(f"Spaghetti chart error: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
def _render_summary_stats(stats) -> None:
|
| 162 |
+
"""Render SummaryStats as metric cards (flat, no nesting)."""
|
| 163 |
row1 = st.columns(4)
|
| 164 |
row1[0].metric("Count", f"{stats.count:,}")
|
| 165 |
row1[1].metric("Missing", f"{stats.missing_count} ({stats.missing_pct:.1f}%)")
|
|
|
|
| 172 |
row2[2].metric("Median", f"{stats.median_val:,.2f}")
|
| 173 |
row2[3].metric("75th %ile / Max", f"{stats.p75:,.2f} / {stats.max_val:,.2f}")
|
| 174 |
|
| 175 |
+
row3 = st.columns(4)
|
| 176 |
+
row3[0].metric(
|
| 177 |
+
"Trend slope",
|
| 178 |
+
f"{stats.trend_slope:,.4f}" if pd.notna(stats.trend_slope) else "N/A",
|
| 179 |
+
help="Slope from OLS on a numeric index.",
|
| 180 |
+
)
|
| 181 |
+
row3[1].metric(
|
| 182 |
+
"Trend p-value",
|
| 183 |
+
f"{stats.trend_pvalue:.4f}" if pd.notna(stats.trend_pvalue) else "N/A",
|
| 184 |
+
)
|
| 185 |
+
row3[2].metric(
|
| 186 |
+
"ADF statistic",
|
| 187 |
+
f"{stats.adf_statistic:.4f}" if pd.notna(stats.adf_statistic) else "N/A",
|
| 188 |
+
help="Augmented Dickey-Fuller test statistic.",
|
| 189 |
+
)
|
| 190 |
+
row3[3].metric(
|
| 191 |
+
"ADF p-value",
|
| 192 |
+
f"{stats.adf_pvalue:.4f}" if pd.notna(stats.adf_pvalue) else "N/A",
|
| 193 |
+
help="p < 0.05 suggests the series is stationary.",
|
| 194 |
+
)
|
| 195 |
+
st.caption(
|
| 196 |
+
f"Date range: {stats.date_start.date()} to {stats.date_end.date()} "
|
| 197 |
+
f"({stats.date_span_days:,} days)"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _render_ai_interpretation(fig, chart_type_label, freq_info, df_plot,
|
| 202 |
+
date_col, y_label, button_key):
|
| 203 |
+
"""Reusable AI Chart Interpretation block for any tab."""
|
| 204 |
+
with st.expander("AI Chart Interpretation", expanded=False):
|
| 205 |
st.caption(
|
| 206 |
+
"The chart image (PNG) is sent to OpenAI for interpretation. "
|
| 207 |
+
"Do not include sensitive data in your charts."
|
| 208 |
)
|
| 209 |
+
if not check_api_key_available():
|
| 210 |
+
st.warning("Set `OPENAI_API_KEY` to enable AI interpretation.")
|
| 211 |
+
elif fig is not None:
|
| 212 |
+
if st.button("Interpret Chart with AI", key=button_key):
|
| 213 |
+
with st.spinner("Analyzing chart..."):
|
| 214 |
+
png = fig_to_png_bytes(fig)
|
| 215 |
+
date_range_str = (
|
| 216 |
+
f"{df_plot[date_col].min().date()} to "
|
| 217 |
+
f"{df_plot[date_col].max().date()}"
|
| 218 |
+
)
|
| 219 |
+
metadata = {
|
| 220 |
+
"chart_type": chart_type_label,
|
| 221 |
+
"frequency_label": freq_info.label if freq_info else "Unknown",
|
| 222 |
+
"date_range": date_range_str,
|
| 223 |
+
"y_column": y_label,
|
| 224 |
+
}
|
| 225 |
+
interp = interpret_chart(png, metadata)
|
| 226 |
+
render_interpretation(interp)
|
| 227 |
|
| 228 |
|
| 229 |
# ---------------------------------------------------------------------------
|
|
|
|
| 243 |
for key in [
|
| 244 |
"raw_df", "cleaned_df", "cleaning_report", "freq_info",
|
| 245 |
"date_col", "y_cols", "qc", "qc_hash",
|
| 246 |
+
"_upload_id", "_upload_delim", "_clean_key",
|
| 247 |
]:
|
| 248 |
if key not in st.session_state:
|
| 249 |
st.session_state[key] = None
|
|
|
|
| 332 |
key="sidebar_missing_action",
|
| 333 |
)
|
| 334 |
|
| 335 |
+
# Clean — only recompute and write session_state when inputs change
|
| 336 |
if y_cols:
|
| 337 |
+
_key = (date_col, tuple(y_cols), dup_action, missing_action,
|
| 338 |
+
st.session_state._upload_id)
|
| 339 |
+
if st.session_state.get("_clean_key") != _key:
|
| 340 |
+
cleaned_df, report, freq_info = _clean_pipeline(
|
| 341 |
+
_df_hash(raw_df), raw_df, date_col, tuple(y_cols),
|
| 342 |
+
dup_action, missing_action,
|
| 343 |
+
)
|
| 344 |
+
st.session_state.cleaned_df = cleaned_df
|
| 345 |
+
st.session_state.cleaning_report = report
|
| 346 |
+
st.session_state.freq_info = freq_info
|
| 347 |
+
st.session_state._clean_key = _key
|
| 348 |
|
| 349 |
+
freq_info = st.session_state.freq_info
|
| 350 |
st.caption(f"Frequency: **{freq_info.label}** "
|
| 351 |
f"({'regular' if freq_info.is_regular else 'irregular'})")
|
| 352 |
|
|
|
|
| 633 |
_render_summary_stats(stats)
|
| 634 |
|
| 635 |
# ---- AI Interpretation ------------------------------------------------
|
| 636 |
+
_render_ai_interpretation(
|
| 637 |
+
fig, chart_type, freq_info, df_plot, date_col, active_y, "interpret_a",
|
| 638 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
# ===================================================================
|
| 641 |
# Tab B — Few Series (Panel)
|
|
|
|
| 665 |
palette_name_b = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_b")
|
| 666 |
palette_b = get_palette_colors(palette_name_b, len(panel_cols))
|
| 667 |
|
| 668 |
+
fig_panel = None
|
| 669 |
try:
|
| 670 |
fig_panel = plot_panel(
|
| 671 |
working_df, date_col, panel_cols,
|
|
|
|
| 696 |
use_container_width=True,
|
| 697 |
)
|
| 698 |
|
| 699 |
+
# AI Interpretation
|
| 700 |
+
_render_ai_interpretation(
|
| 701 |
+
fig_panel, f"Panel ({panel_chart})", freq_info,
|
| 702 |
+
working_df, date_col, ", ".join(panel_cols), "interpret_b",
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
# ===================================================================
|
| 706 |
# Tab C — Many Series (Spaghetti)
|
| 707 |
# ===================================================================
|
|
|
|
| 738 |
palette_name_c = st.selectbox("Color palette", _PALETTE_NAMES, key="pal_c")
|
| 739 |
palette_c = get_palette_colors(palette_name_c, len(spag_cols))
|
| 740 |
|
| 741 |
+
fig_spag = None
|
| 742 |
try:
|
| 743 |
fig_spag = plot_spaghetti(
|
| 744 |
working_df, date_col, spag_cols,
|
|
|
|
| 753 |
st.pyplot(fig_spag, use_container_width=True)
|
| 754 |
except Exception as exc:
|
| 755 |
st.error(f"Spaghetti chart error: {exc}")
|
| 756 |
+
|
| 757 |
+
# Per-series summary table
|
| 758 |
+
with st.expander("Per-series Summary", expanded=False):
|
| 759 |
+
spag_summary = compute_multi_series_summary(
|
| 760 |
+
working_df, date_col, spag_cols,
|
| 761 |
+
)
|
| 762 |
+
st.dataframe(
|
| 763 |
+
spag_summary.style.format({
|
| 764 |
+
"mean": "{:,.2f}",
|
| 765 |
+
"std": "{:,.2f}",
|
| 766 |
+
"min": "{:,.2f}",
|
| 767 |
+
"max": "{:,.2f}",
|
| 768 |
+
"trend_slope": "{:,.4f}",
|
| 769 |
+
"adf_pvalue": "{:.4f}",
|
| 770 |
+
}),
|
| 771 |
+
use_container_width=True,
|
| 772 |
+
)
|
| 773 |
+
|
| 774 |
+
# AI Interpretation
|
| 775 |
+
_render_ai_interpretation(
|
| 776 |
+
fig_spag, "Spaghetti Plot", freq_info,
|
| 777 |
+
working_df, date_col, ", ".join(spag_cols), "interpret_c",
|
| 778 |
+
)
|