causalscience commited on
Commit
51836ac
·
verified ·
1 Parent(s): e15b260

UI for timeseries

Browse files
Files changed (1) hide show
  1. ui/timeseries_tab.py +499 -0
ui/timeseries_tab.py ADDED
@@ -0,0 +1,499 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys, subprocess
4
+ def _ensure(pkg):
5
+ try:
6
+ __import__(pkg.split("==")[0].split(">=")[0])
7
+ except Exception:
8
+ subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
9
+ for _pkg in ["gradio", "pandas", "numpy", "matplotlib"]:
10
+ _ensure(_pkg)
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from datetime import datetime
15
+ import zipfile # ADDED
16
+ import io # ADDED
17
+ import gradio as gr
18
+ import pandas as pd
19
+ import numpy as np
20
+ from typing import List, Optional
21
+
22
+ def _export_dir() -> Path:
23
+ candidates = [
24
+ Path(os.getenv("HF_MNT_DIR", "")).expanduser(),
25
+ Path("/mnt/data"),
26
+ Path.cwd() / "exports",
27
+ ]
28
+ for p in candidates:
29
+ try:
30
+ if p and str(p).strip():
31
+ p.mkdir(parents=True, exist_ok=True)
32
+ return p
33
+ except Exception:
34
+ continue
35
+ return Path.cwd()
36
+
37
+ def _import_models():
38
+ from timeseries_forecasting import (
39
+ run_auto_arima_forecast,
40
+ run_ets_forecast,
41
+ run_prophet_forecast,
42
+ run_sarimax_forecast,
43
+ perform_stationarity_tests,
44
+ detect_outliers,
45
+ )
46
+ return (
47
+ run_auto_arima_forecast,
48
+ run_ets_forecast,
49
+ run_prophet_forecast,
50
+ run_sarimax_forecast,
51
+ perform_stationarity_tests,
52
+ detect_outliers,
53
+ )
54
+
55
+ def timeseries_tab():
56
+ (
57
+ run_auto_arima_forecast,
58
+ run_ets_forecast,
59
+ run_prophet_forecast,
60
+ run_sarimax_forecast,
61
+ perform_stationarity_tests,
62
+ detect_outliers,
63
+ ) = _import_models()
64
+
65
+ with gr.Column():
66
+ gr.Markdown("## Time Series Forecasting")
67
+ file_input = gr.File(label="Upload CSV with date, target, optional regressors", type="filepath")
68
+
69
+ # --- Data configuration ---
70
+ with gr.Group():
71
+ gr.Markdown("### Data Configuration")
72
+ date_col = gr.Dropdown(label="Date column", interactive=True)
73
+ target_col = gr.Dropdown(label="Target (numeric)", interactive=True)
74
+ exog_cols = gr.Dropdown(label="Exogenous regressors (optional; numeric only)", interactive=True, multiselect=True)
75
+
76
+ data_preview = gr.Dataframe(label="Preview (first 12 rows)", interactive=False, visible=False)
77
+ data_info = gr.Textbox(label="Data summary", lines=4, interactive=False, visible=False)
78
+
79
+ # --- Train / Forecast controls ---
80
+ with gr.Group():
81
+ gr.Markdown("### Train / Forecast Controls")
82
+ train_start = gr.Textbox(label="Train start (optional, YYYY-MM-DD)", placeholder="auto")
83
+ train_end = gr.Textbox(label="Train end (optional, YYYY-MM-DD)", placeholder="auto")
84
+ horizon = gr.Number(value=12, label="Forecast horizon H (steps)", precision=0)
85
+ freq = gr.Dropdown(
86
+ label="Frequency",
87
+ value="infer",
88
+ choices=["infer", "D", "W-MON", "MS", "M", "Q", "H"]
89
+ )
90
+
91
+ # --- Model selection & params ---
92
+ with gr.Group():
93
+ gr.Markdown("### Model & Parameters")
94
+ model = gr.Dropdown(
95
+ label="Model",
96
+ choices=["Auto-ARIMA", "ETS", "Prophet", "SARIMAX"],
97
+ value="Auto-ARIMA",
98
+ )
99
+
100
+ with gr.Accordion("Auto-ARIMA / SARIMAX settings", open=False, visible=True) as aa_group:
101
+ aa_seasonal = gr.Checkbox(value=False, label="Seasonal")
102
+ aa_m = gr.Number(value=12, label="Seasonal period m", precision=0)
103
+
104
+ with gr.Accordion("ETS settings", open=False, visible=False) as ets_group:
105
+ ets_error = gr.Dropdown(choices=["add", "mul"], value="add", label="Error")
106
+ ets_trend = gr.Dropdown(choices=["none", "add", "mul"], value="none", label="Trend")
107
+ ets_seasonal = gr.Dropdown(choices=["none", "add", "mul"], value="none", label="Seasonal")
108
+ ets_m = gr.Number(value=1, label="Seasonal periods (m)", precision=0)
109
+ ets_damped = gr.Checkbox(value=False, label="Damped trend")
110
+
111
+ with gr.Accordion("Prophet settings", open=False, visible=False) as pr_group:
112
+ pr_mode = gr.Dropdown(choices=["additive", "multiplicative"], value="additive", label="Seasonality mode")
113
+ pr_yearly = gr.Checkbox(value=True, label="Yearly")
114
+ pr_weekly = gr.Checkbox(value=True, label="Weekly")
115
+ pr_daily = gr.Checkbox(value=False, label="Daily")
116
+
117
+ # --- Exogenous handling controls ---
118
+ with gr.Row():
119
+ exog_policy = gr.Dropdown(
120
+ label="Exogenous handling",
121
+ value="auto_forecast",
122
+ choices=["auto_forecast", "drop_if_missing", "require_future"],
123
+ info="How to handle future exogenous values if missing in the file."
124
+ )
125
+ exog_method = gr.Dropdown(
126
+ label="Exog forecast method",
127
+ value="naive",
128
+ choices=["naive", "seasonal_naive", "auto_arima"],
129
+ )
130
+ exog_m = gr.Number(
131
+ value=0,
132
+ label="Exog seasonal period (m)",
133
+ precision=0,
134
+ info="Used for seasonal-naive and seasonal ARIMA; set m>1 to enable seasonality."
135
+ )
136
+
137
+ run_btn = gr.Button("Run Forecast", variant="primary")
138
+
139
+ show_diag = gr.Checkbox(value=True, label="Show residual diagnostics")
140
+
141
+ export_toggle = gr.Checkbox(value=False, label="Enable export widgets", visible=False)
142
+
143
+ # --- Outputs ---
144
+ fig_out = gr.Plot(label="Forecast")
145
+ summary_out = gr.Textbox(label="Summary", lines=16)
146
+ diag_out = gr.Plot(label="Diagnostics", visible=False)
147
+ metrics_out = gr.Textbox(label="Quick metrics", lines=3, visible=False)
148
+ residual_out = gr.Textbox(label="Residual info", lines=8, visible=False)
149
+ forecast_store = gr.State() # holds last forecast DataFrame
150
+
151
+ fig_state = gr.State() # ADDED: last forecast figure
152
+ diag_state = gr.State() # ADDED: last diagnostics figure (optional)
153
+ summary_state = gr.State() # ADDED: last summary string
154
+
155
+ with gr.Row() as export_row: # MODIFIED: visible by default
156
+ download_csv_btn = gr.DownloadButton("Download forecast CSV")
157
+ export_report_btn = gr.DownloadButton("Export full report (ZIP)") # MODIFIED: was Button, now DownloadButton
158
+
159
+ # --- Diagnostics ---
160
+ with gr.Accordion("Advanced diagnostics (optional)", open=False):
161
+ analyze_btn = gr.Button("Run stationarity & outlier scan", variant="secondary")
162
+ stationarity_txt = gr.Textbox(label="Stationarity tests (ADF, KPSS)", lines=8, interactive=False)
163
+ outlier_txt = gr.Textbox(label="Outlier scan", lines=2, interactive=False)
164
+
165
+ # ---------- Callbacks ----------
166
+
167
+ def _read_csv(fp):
168
+ if not fp:
169
+ return (
170
+ gr.update(choices=[], value=None),
171
+ gr.update(choices=[], value=None),
172
+ gr.update(choices=[], value=[]),
173
+ gr.update(visible=False),
174
+ gr.update(visible=False),
175
+ )
176
+ try:
177
+ df = pd.read_csv(fp)
178
+ df.columns = df.columns.str.strip() # MODIFIED: trim whitespace in headers
179
+ except Exception as e:
180
+ gr.Warning(f"Failed to read CSV: {e}")
181
+ return (
182
+ gr.update(choices=[], value=None),
183
+ gr.update(choices=[], value=None),
184
+ gr.update(choices=[], value=[]),
185
+ gr.update(visible=False),
186
+ gr.update(visible=True, value=f"Error: {e}"),
187
+ )
188
+
189
+ cols = df.columns.tolist()
190
+ # heuristic: first datetime-like as date, last numeric as target
191
+ date_guess = None
192
+ for c in cols:
193
+ try:
194
+ pd.to_datetime(df[c])
195
+ date_guess = c
196
+ break
197
+ except Exception:
198
+ continue
199
+ num_cols = [c for c in cols if pd.api.types.is_numeric_dtype(df[c])]
200
+ tgt_guess = num_cols[-1] if num_cols else None
201
+
202
+ info = f"Shape: {df.shape[0]} x {df.shape[1]}\n"
203
+ if date_guess:
204
+ dt = pd.to_datetime(df[date_guess], errors="coerce")
205
+ info += f"Date range in file: {dt.min()} → {dt.max()}\n"
206
+ info += f"Numeric columns: {', '.join(num_cols[:6])}{'...' if len(num_cols)>6 else ''}\n"
207
+ info += f"Missing cells: {int(df.isna().sum().sum())}"
208
+
209
+ preview = df.head(12)
210
+
211
+ return (
212
+ gr.update(choices=cols, value=date_guess),
213
+ gr.update(choices=cols, value=tgt_guess),
214
+ gr.update(choices=[c for c in num_cols], value=[]),
215
+ gr.update(visible=True, value=preview),
216
+ gr.update(visible=True, value=info),
217
+ )
218
+
219
+ file_input.change(
220
+ _read_csv,
221
+ inputs=[file_input],
222
+ outputs=[date_col, target_col, exog_cols, data_preview, data_info],
223
+ )
224
+
225
+ def _analyze(fp, dcol, tcol):
226
+ if not fp or not dcol or not tcol:
227
+ return "Upload a CSV and select columns.", "—"
228
+ df = pd.read_csv(fp)
229
+ df.columns = df.columns.str.strip() # MODIFIED: trim whitespace in headers
230
+ missing = [name for name in [dcol, tcol] if name not in df.columns]
231
+ if missing:
232
+ return (f"Selected column(s) not found: {', '.join(missing)}.\n"
233
+ f"Available columns: {', '.join(df.columns.tolist())}", "—")
234
+ df = df[[dcol, tcol]].dropna(subset=[dcol])
235
+ dfi = df.copy()
236
+ dfi[dcol] = pd.to_datetime(dfi[dcol], errors="coerce")
237
+ dfi = dfi.sort_values(dcol).set_index(dcol)
238
+ try:
239
+ st = perform_stationarity_tests(dfi, tcol)
240
+ except Exception as e:
241
+ st = f"Stationarity test error: {e}"
242
+ try:
243
+ ot = detect_outliers(dfi, tcol)
244
+ except Exception as e:
245
+ ot = f"Outlier detection error: {e}"
246
+ return st, ot
247
+
248
+ analyze_btn.click(
249
+ _analyze, inputs=[file_input, date_col, target_col], outputs=[stationarity_txt, outlier_txt]
250
+ )
251
+
252
+ def _toggle_param_visibility(model_name: str):
253
+ return (
254
+ gr.update(visible=model_name in ["Auto-ARIMA", "SARIMAX"]),
255
+ gr.update(visible=model_name == "ETS"),
256
+ gr.update(visible=model_name == "Prophet"),
257
+ )
258
+
259
+ model.change(
260
+ _toggle_param_visibility,
261
+ inputs=[model],
262
+ outputs=[aa_group, ets_group, pr_group],
263
+ )
264
+
265
+ # Forecast callback
266
+ def _forecast(
267
+ fp, dcol, tcol,
268
+ model_name,
269
+ H, FREQ, show_d,
270
+ aa_seas, aa_m,
271
+ ets_err, ets_tr, ets_seas, ets_m_per, ets_damp,
272
+ pr_mode, pr_year, pr_week, pr_day,
273
+ exog_selected,
274
+ exog_policy_val, exog_method_val, exog_m_val,
275
+ tr_start, tr_end
276
+ ):
277
+ if not fp:
278
+ return (None, "Error: upload a CSV.", gr.update(visible=False), gr.update(visible=False),
279
+ gr.update(visible=False), None, None, None, None) # MODIFIED: extra Nones for states
280
+ try:
281
+ df = pd.read_csv(fp)
282
+ df.columns = df.columns.str.strip() # MODIFIED
283
+ except Exception as e:
284
+ return (None, f"CSV read error: {e}", gr.update(visible=False), gr.update(visible=False),
285
+ gr.update(visible=False), None, None, None, None)
286
+
287
+ if dcol not in df.columns or tcol not in df.columns:
288
+ return (None, f"Error: selected column(s) not found. Have: {', '.join(df.columns.tolist())}",
289
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
290
+ None, None, None, None)
291
+
292
+ dfi = df.copy()
293
+ dfi[dcol] = pd.to_datetime(dfi[dcol], errors="coerce")
294
+ dfi = dfi.sort_values(dcol).set_index(dcol)
295
+
296
+ exog_selected = [c for c in (exog_selected or []) if c in dfi.columns and c != tcol]
297
+
298
+ try:
299
+ if model_name == "Auto-ARIMA":
300
+ res = run_auto_arima_forecast(
301
+ dfi, tcol, int(H),
302
+ bool(aa_seas), int(aa_m) if aa_seas else 1,
303
+ freq=FREQ,
304
+ exog_cols=exog_selected or None,
305
+ future_exog_df=None,
306
+ train_start=tr_start or None,
307
+ train_end=tr_end or None,
308
+ return_diagnostics=bool(show_d),
309
+ exog_policy=exog_policy_val,
310
+ exog_method=exog_method_val,
311
+ exog_m=int(exog_m_val or 0),
312
+ )
313
+ elif model_name == "ETS":
314
+ res = run_ets_forecast(
315
+ dfi, tcol, int(H),
316
+ ets_err, ets_tr, ets_seas, int(ets_m_per), bool(ets_damp),
317
+ freq=FREQ,
318
+ train_start=tr_start or None,
319
+ train_end=tr_end or None,
320
+ return_diagnostics=bool(show_d),
321
+ )
322
+ elif model_name == "Prophet":
323
+ res = run_prophet_forecast(
324
+ dfi, tcol, int(H),
325
+ pr_mode, bool(pr_year), bool(pr_week), bool(pr_day),
326
+ freq=FREQ,
327
+ exog_cols=exog_selected or None,
328
+ future_exog_df=None,
329
+ train_start=tr_start or None,
330
+ train_end=tr_end or None,
331
+ return_diagnostics=bool(show_d),
332
+ exog_policy=exog_policy_val,
333
+ exog_method=exog_method_val,
334
+ exog_m=int(exog_m_val or 0),
335
+ )
336
+ elif model_name == "SARIMAX":
337
+ res = run_sarimax_forecast(
338
+ dfi, tcol, int(H),
339
+ bool(aa_seas), int(aa_m) if aa_seas else 1,
340
+ freq=FREQ,
341
+ exog_cols=exog_selected or None,
342
+ future_exog_df=None,
343
+ train_start=tr_start or None,
344
+ train_end=tr_end or None,
345
+ return_diagnostics=bool(show_d),
346
+ exog_policy=exog_policy_val,
347
+ exog_method=exog_method_val,
348
+ exog_m=int(exog_m_val or 0),
349
+ )
350
+ else:
351
+ return (None, f"Unknown model: {model_name}", gr.update(visible=False), gr.update(visible=False),
352
+ gr.update(visible=False), None, None, None, None)
353
+
354
+ fig, summary, diag_fig, yhat, conf_df = res
355
+
356
+ # Build CSV DataFrame for download
357
+ csv_df = None
358
+ if yhat is not None:
359
+ csv_df = pd.DataFrame({
360
+ "timestamp": pd.Index(yhat.index, name="timestamp"),
361
+ "forecast": yhat.values,
362
+ })
363
+ if conf_df is not None and all(k in conf_df.columns for k in ["lower", "upper"]):
364
+ csv_df["lower"] = np.asarray(conf_df["lower"])
365
+ csv_df["upper"] = np.asarray(conf_df["upper"])
366
+ csv_df = csv_df.reset_index(drop=True)
367
+
368
+ metrics_text = ""
369
+ if isinstance(summary, str):
370
+ lines = summary.splitlines()
371
+ metrics_text = "\n".join([ln for ln in lines if any(k in ln for k in ["MAE:", "RMSE:", "MAPE:"])])
372
+
373
+ # MODIFIED: return states so we can export a full report later
374
+ return (
375
+ fig,
376
+ summary,
377
+ gr.update(visible=bool(show_d) and diag_fig is not None, value=diag_fig if diag_fig is not None else None),
378
+ gr.update(visible=bool(metrics_text), value=metrics_text if metrics_text else None),
379
+ gr.update(visible=False),
380
+ csv_df,
381
+ fig, # ADDED: fig_state
382
+ diag_fig, # ADDED: diag_state
383
+ summary # ADDED: summary_state
384
+ )
385
+ except Exception as e:
386
+ return (None, f"Error: {e}", gr.update(visible=False), gr.update(visible=False),
387
+ gr.update(visible=False), None, None, None, None)
388
+
389
+ run_btn.click(
390
+ _forecast,
391
+ inputs=[
392
+ file_input, date_col, target_col,
393
+ model,
394
+ horizon, freq, show_diag,
395
+ aa_seasonal, aa_m,
396
+ ets_error, ets_trend, ets_seasonal, ets_m, ets_damped,
397
+ pr_mode, pr_yearly, pr_weekly, pr_daily,
398
+ exog_cols,
399
+ exog_policy, exog_method, exog_m,
400
+ train_start, train_end,
401
+ ],
402
+ outputs=[
403
+ fig_out, summary_out, diag_out, metrics_out, residual_out, forecast_store,
404
+ fig_state, diag_state, summary_state # ADDED
405
+ ],
406
+ )
407
+
408
+ def _prepare_csv(forecast_df: Optional[pd.DataFrame]):
409
+ if forecast_df is None or not isinstance(forecast_df, pd.DataFrame) or forecast_df.empty:
410
+ return gr.update(value=None)
411
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
412
+ save_dir = _export_dir()
413
+ path = save_dir / f"forecast_{ts}.csv"
414
+ forecast_df.to_csv(path, index=False)
415
+ return gr.update(value=str(path)) # DownloadButton(value=path)
416
+
417
+ download_csv_btn.click(_prepare_csv, inputs=[forecast_store], outputs=[download_csv_btn])
418
+
419
+ def _export_report(fig_obj, diag_obj, summary_text, forecast_df: Optional[pd.DataFrame]):
420
+ if fig_obj is None and (forecast_df is None or forecast_df.empty) and not summary_text:
421
+ return gr.update(value=None)
422
+
423
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
424
+ save_dir = _export_dir()
425
+ work_dir = save_dir / f"report_{ts}"
426
+ work_dir.mkdir(parents=True, exist_ok=True)
427
+
428
+ # Save CSV (if any)
429
+ csv_path = None
430
+ if isinstance(forecast_df, pd.DataFrame) and not forecast_df.empty:
431
+ csv_path = work_dir / "forecast.csv"
432
+ forecast_df.to_csv(csv_path, index=False)
433
+
434
+ # Best-effort save of forecast plot
435
+ plot_path = None
436
+ if fig_obj is not None:
437
+ plot_path = work_dir / "forecast_plot.png"
438
+ try:
439
+ # If matplotlib Figure-like
440
+ if hasattr(fig_obj, "savefig"):
441
+ fig_obj.savefig(plot_path, bbox_inches="tight", dpi=180)
442
+ # If plotly Figure-like with to_image (avoid extra deps; may fail)
443
+ elif hasattr(fig_obj, "to_image"):
444
+ img_bytes = fig_obj.to_image(format="png") # requires kaleido; may raise
445
+ with open(plot_path, "wb") as f:
446
+ f.write(img_bytes)
447
+ else:
448
+ plot_path = None # unsupported figure type
449
+ except Exception:
450
+ plot_path = None # keep going; we still zip other artifacts
451
+
452
+ diag_path = None
453
+ if diag_obj is not None:
454
+ diag_path = work_dir / "diagnostics.png"
455
+ try:
456
+ if hasattr(diag_obj, "savefig"):
457
+ diag_obj.savefig(diag_path, bbox_inches="tight", dpi=180)
458
+ elif hasattr(diag_obj, "to_image"):
459
+ img_bytes = diag_obj.to_image(format="png")
460
+ with open(diag_path, "wb") as f:
461
+ f.write(img_bytes)
462
+ else:
463
+ diag_path = None
464
+ except Exception:
465
+ diag_path = None
466
+
467
+ # Save summary text
468
+ summary_path = None
469
+ if isinstance(summary_text, str) and summary_text.strip():
470
+ summary_path = work_dir / "summary.txt"
471
+ with open(summary_path, "w", encoding="utf-8") as f:
472
+ f.write(summary_text)
473
+
474
+ # Zip everything that exists
475
+ zip_path = save_dir / f"full_report_{ts}.zip"
476
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
477
+ for p in [csv_path, plot_path, diag_path, summary_path]:
478
+ if p and p.exists():
479
+ zf.write(p, arcname=p.name)
480
+
481
+ return gr.update(value=str(zip_path))
482
+
483
+ export_report_btn.click(
484
+ _export_report,
485
+ inputs=[fig_state, diag_state, summary_state, forecast_store],
486
+ outputs=[export_report_btn]
487
+ )
488
+
489
+ return [
490
+ file_input, date_col, target_col, exog_cols,
491
+ model, horizon, aa_group, ets_group, pr_group,
492
+ exog_policy, exog_method, exog_m,
493
+ run_btn, show_diag, fig_out, summary_out, diag_out, metrics_out, residual_out,
494
+ export_toggle, export_row, # export_row now always visible; toggle is hidden
495
+ freq, train_start, train_end,
496
+ forecast_store, download_csv_btn,
497
+ ]
498
+
499
+ __all__ = ["timeseries_tab"]