UCS2014 commited on
Commit
f16ed84
·
verified ·
1 Parent(s): ee9900b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +27 -968
app.py CHANGED
@@ -1,369 +1,9 @@
1
- # app.py — ST_GeoMech_YM (Young's Modulus), mirrors UCS GUI
2
-
3
- import io, json, os, base64, math
4
- from pathlib import Path
5
- import streamlit as st
6
- import pandas as pd
7
- import numpy as np
8
- import joblib
9
- from datetime import datetime
10
-
11
- # Matplotlib for PREVIEW modal and the CROSS-PLOT (static)
12
- import matplotlib
13
- matplotlib.use("Agg")
14
- import matplotlib.pyplot as plt
15
- from matplotlib.ticker import FuncFormatter
16
-
17
- import plotly.graph_objects as go
18
- from sklearn.metrics import mean_squared_error, mean_absolute_error
19
-
20
- # =========================
21
- # App identity (YM)
22
- # =========================
23
- APP_NAME = "ST_GeoMech_YM"
24
- TAGLINE = "Real-Time Young's Modulus Tracking"
25
-
26
- # =========================
27
- # Constants (YM)
28
- # =========================
29
- FEATURES = [
30
- "WOB(klbf)", "TORQUE(kft.lbf)", "SPP(psi)", "RPM(1/min)",
31
- "ROP(ft/h)", "Flow Rate, gpm"
32
- ]
33
- TARGET = "Actual Ym" # column with actual Young's Modulus
34
- PRED_COL = "Ym_Pred" # column we will create with predictions
35
-
36
- MODELS_DIR = Path("models")
37
- DEFAULT_MODEL = MODELS_DIR / "ym_rf.joblib"
38
- MODEL_FALLBACKS = [MODELS_DIR / "model.joblib", MODELS_DIR / "model.pkl"]
39
-
40
- COLORS = {"pred": "#1f77b4", "actual": "#f2b702", "ref": "#5a5a5a"}
41
-
42
- # ---- Plot sizing controls ----
43
- CROSS_W = 350
44
- CROSS_H = 350
45
- TRACK_H = 1000
46
- TRACK_W = 400
47
- FONT_SZ = 13
48
- BOLD_FONT = "Arial Black, Arial, sans-serif"
49
-
50
- # =========================
51
- # Page / CSS
52
- # =========================
53
- st.set_page_config(page_title=APP_NAME, page_icon="logo.png", layout="wide")
54
-
55
- # General CSS
56
- st.markdown("""
57
- <style>
58
- .brand-logo { width: 200px; height: auto; object-fit: contain; }
59
- .sidebar-header { display:flex; align-items:center; gap:12px; }
60
- .sidebar-header .text h1 { font-size: 1.05rem; margin:0; line-height:1.1; }
61
- .sidebar-header .text .tag { font-size: .85rem; color:#6b7280; margin:2px 0 0; }
62
- .centered-container { display:flex; flex-direction:column; align-items:center; text-align:center; }
63
- </style>
64
- """, unsafe_allow_html=True)
65
-
66
- # Allow sticky bits (preview expander header & tabs)
67
- st.markdown("""
68
- <style>
69
- .main .block-container { overflow: unset !important; }
70
- div[data-testid="stVerticalBlock"] { overflow: unset !important; }
71
- </style>
72
- """, unsafe_allow_html=True)
73
-
74
- # Hide uploader helper text
75
- st.markdown("""
76
- <style>
77
- section[data-testid="stFileUploader"] div[data-testid="stMarkdownContainer"]{display:none !important;}
78
- section[data-testid="stFileUploader"] [data-testid="stFileUploaderDropzone"] > div:first-child{display:none !important;}
79
- section[data-testid="stFileUploader"] [data-testid="stFileUploaderInstructions"]{display:none !important;}
80
- section[data-testid="stFileUploader"] p, section[data-testid="stFileUploader"] small{display:none !important;}
81
- </style>
82
- """, unsafe_allow_html=True)
83
-
84
- # Sticky Preview expander & tabs
85
- st.markdown("""
86
- <style>
87
- div[data-testid="stExpander"] > details > summary {
88
- position: sticky; top: 0; z-index: 10; background: #fff; border-bottom: 1px solid #eee;
89
- }
90
- div[data-testid="stExpander"] div[data-baseweb="tab-list"] {
91
- position: sticky; top: 42px; z-index: 9; background: #fff; padding-top: 6px;
92
- }
93
- </style>
94
- """, unsafe_allow_html=True)
95
-
96
- # Center text in pandas tables
97
- TABLE_CENTER_CSS = [
98
- dict(selector="th", props=[("text-align", "center")]),
99
- dict(selector="td", props=[("text-align", "center")]),
100
- ]
101
-
102
- # Message box styles
103
- st.markdown("""
104
- <style>
105
- .st-message-box { background:#f0f2f6; color:#333; padding:10px; border-radius:10px; border:1px solid #e6e9ef; }
106
- .st-message-box.st-success { background:#d4edda; color:#155724; border-color:#c3e6cb; }
107
- .st-message-box.st-warning { background:#fff3cd; color:#856404; border-color:#ffeeba; }
108
- .st-message-box.st-error { background:#f8d7da; color:#721c24; border-color:#f5c6cb; }
109
- </style>
110
- """, unsafe_allow_html=True)
111
-
112
- # =========================
113
- # Password gate
114
- # =========================
115
- def inline_logo(path="logo.png") -> str:
116
- try:
117
- p = Path(path)
118
- if not p.exists():
119
- return ""
120
- return f"data:image/png;base64,{base64.b64encode(p.read_bytes()).decode('ascii')}"
121
- except Exception:
122
- return ""
123
-
124
- def add_password_gate() -> None:
125
- try:
126
- required = st.secrets.get("APP_PASSWORD", "")
127
- except Exception:
128
- required = os.environ.get("APP_PASSWORD", "")
129
-
130
- if not required:
131
- st.warning("Set APP_PASSWORD in Secrets (or environment) and restart.")
132
- st.stop()
133
-
134
- if st.session_state.get("auth_ok", False):
135
- return
136
-
137
- st.sidebar.markdown(f"""
138
- <div class="centered-container">
139
- <img src="{inline_logo('logo.png')}" style="width: 200px; height: auto; object-fit: contain;">
140
- <div style='font-weight:800;font-size:1.2rem;'>{APP_NAME}</div>
141
- <div style='color:#667085;'>Smart Thinking • Secure Access</div>
142
- </div>
143
- """, unsafe_allow_html=True
144
- )
145
- pwd = st.sidebar.text_input("Access key", type="password", placeholder="••••••••")
146
- if st.sidebar.button("Unlock", type="primary"):
147
- if pwd == required:
148
- st.session_state.auth_ok = True
149
- st.rerun()
150
- else:
151
- st.error("Incorrect key.")
152
- st.stop()
153
-
154
- add_password_gate()
155
-
156
- # =========================
157
- # Utilities
158
- # =========================
159
- def rmse(y_true, y_pred) -> float:
160
- return float(np.sqrt(mean_squared_error(y_true, y_pred)))
161
-
162
- def pearson_r(y_true, y_pred) -> float:
163
- a = np.asarray(y_true, dtype=float)
164
- p = np.asarray(y_pred, dtype=float)
165
- if a.size < 2:
166
- return float("nan")
167
- return float(np.corrcoef(a, p)[0, 1])
168
-
169
- @st.cache_resource(show_spinner=False)
170
- def load_model(model_path: str):
171
- return joblib.load(model_path)
172
-
173
- @st.cache_data(show_spinner=False)
174
- def parse_excel(data_bytes: bytes):
175
- bio = io.BytesIO(data_bytes)
176
- xl = pd.ExcelFile(bio)
177
- return {sh: xl.parse(sh) for sh in xl.sheet_names}
178
-
179
- def read_book_bytes(b: bytes):
180
- return parse_excel(b) if b else {}
181
-
182
- def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
183
- out = df.copy()
184
- out.columns = [str(c).strip() for c in out.columns]
185
- # Fix common typos
186
- out = out.rename(columns={
187
- "Fow Rate, gpm": "Flow Rate, gpm",
188
- "Fow Rate, gpm ": "Flow Rate, gpm"
189
- })
190
- return out
191
-
192
- def ensure_cols(df, cols):
193
- miss = [c for c in cols if c not in df.columns]
194
- if miss:
195
- st.error(f"Missing columns: {miss}\nFound: {list(df.columns)}")
196
- return False
197
- return True
198
-
199
- def find_sheet(book, names):
200
- low2orig = {k.lower(): k for k in book.keys()}
201
- for nm in names:
202
- if nm.lower() in low2orig:
203
- return low2orig[nm.lower()]
204
- return None
205
-
206
- def _nice_tick0(xmin: float, step: float = 0.1) -> float:
207
- return step * math.floor(xmin / step) if np.isfinite(xmin) else xmin
208
-
209
- def df_centered_rounded(df: pd.DataFrame, hide_index=True):
210
- out = df.copy()
211
- numcols = out.select_dtypes(include=[np.number]).columns
212
- styler = (
213
- out.style
214
- .format({c: "{:.2f}" for c in numcols})
215
- .set_properties(**{"text-align": "center"})
216
- .set_table_styles(TABLE_CENTER_CSS)
217
- )
218
- st.dataframe(styler, use_container_width=True, hide_index=hide_index)
219
-
220
- # === Excel export helpers (YM) ================================================
221
- def _excel_engine() -> str:
222
- try:
223
- import xlsxwriter # noqa: F401
224
- return "xlsxwriter"
225
- except Exception:
226
- return "openpyxl"
227
-
228
- def _excel_safe_name(name: str) -> str:
229
- bad = '[]:*?/\\'
230
- safe = ''.join('_' if ch in bad else ch for ch in str(name))
231
- return safe[:31]
232
-
233
- def _round_numeric(df: pd.DataFrame, ndigits: int = 3) -> pd.DataFrame:
234
- out = df.copy()
235
- for c in out.columns:
236
- if pd.api.types.is_float_dtype(out[c]) or pd.api.types.is_integer_dtype(out[c]):
237
- out[c] = pd.to_numeric(out[c], errors="coerce").round(ndigits)
238
- return out
239
-
240
- def _summary_table(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
241
- cols = [c for c in cols if c in df.columns]
242
- if not cols:
243
- return pd.DataFrame()
244
- return (
245
- df[cols]
246
- .agg(['min','max','mean','std'])
247
- .T.rename(columns={"min":"Min","max":"Max","mean":"Mean","std":"Std"})
248
- .reset_index(names="Field")
249
- )
250
-
251
- def _train_ranges_df(ranges: dict[str, tuple[float, float]]) -> pd.DataFrame:
252
- if not ranges:
253
- return pd.DataFrame()
254
- df = pd.DataFrame(ranges).T.reset_index()
255
- df.columns = ["Feature", "Min", "Max"]
256
- return df
257
-
258
- def _excel_autofit(writer, sheet_name: str, df: pd.DataFrame, min_w: int = 8, max_w: int = 40):
259
- try:
260
- import xlsxwriter # noqa: F401
261
- except Exception:
262
- return
263
- ws = writer.sheets[sheet_name]
264
- for i, col in enumerate(df.columns):
265
- series = df[col].astype(str)
266
- max_len = max([len(str(col))] + series.map(len).tolist())
267
- ws.set_column(i, i, max(min_w, min(max_len + 2, max_w)))
268
- ws.freeze_panes(1, 0)
269
-
270
- def _add_sheet(sheets: dict, order: list, name: str, df: pd.DataFrame, ndigits: int = 3):
271
- if isinstance(df, pd.DataFrame) and not df.empty:
272
- sheets[name] = _round_numeric(df, ndigits)
273
- order.append(name)
274
-
275
- def _available_sections():
276
- res = st.session_state.get("results", {})
277
- sections = []
278
- if "Train" in res: sections += ["Training","Training_Metrics","Training_Summary"]
279
- if "Test" in res: sections += ["Testing","Testing_Metrics","Testing_Summary"]
280
- if "Validate" in res: sections += ["Validation","Validation_Metrics","Validation_Summary","Validation_OOR"]
281
- if "PredictOnly" in res: sections += ["Prediction","Prediction_Summary"]
282
- if st.session_state.get("train_ranges"): sections += ["Training_Ranges"]
283
- sections += ["Info"]
284
- return sections
285
-
286
- def build_export_workbook(selected: list[str], ndigits: int = 3) -> tuple[bytes|None, str|None, list[str]]:
287
- """Builds an in-memory Excel workbook for selected sheets; fixed rounding to 3 decimals."""
288
- res = st.session_state.get("results", {})
289
- if not res:
290
- return None, None, []
291
-
292
- sheets: dict[str, pd.DataFrame] = {}
293
- order: list[str] = []
294
-
295
- # Training
296
- if "Training" in selected and "Train" in res:
297
- _add_sheet(sheets, order, "Training", res["Train"], ndigits)
298
- if "Training_Metrics" in selected and res.get("m_train"):
299
- _add_sheet(sheets, order, "Training_Metrics", pd.DataFrame([res["m_train"]]), ndigits)
300
- if "Training_Summary" in selected and "Train" in res:
301
- tr_cols = FEATURES + [c for c in [TARGET, PRED_COL] if c in res["Train"].columns]
302
- _add_sheet(sheets, order, "Training_Summary", _summary_table(res["Train"], tr_cols), ndigits)
303
-
304
- # Testing
305
- if "Testing" in selected and "Test" in res:
306
- _add_sheet(sheets, order, "Testing", res["Test"], ndigits)
307
- if "Testing_Metrics" in selected and res.get("m_test"):
308
- _add_sheet(sheets, order, "Testing_Metrics", pd.DataFrame([res["m_test"]]), ndigits)
309
- if "Testing_Summary" in selected and "Test" in res:
310
- te_cols = FEATURES + [c for c in [TARGET, PRED_COL] if c in res["Test"].columns]
311
- _add_sheet(sheets, order, "Testing_Summary", _summary_table(res["Test"], te_cols), ndigits)
312
-
313
- # Validation
314
- if "Validation" in selected and "Validate" in res:
315
- _add_sheet(sheets, order, "Validation", res["Validate"], ndigits)
316
- if "Validation_Metrics" in selected and res.get("m_val"):
317
- _add_sheet(sheets, order, "Validation_Metrics", pd.DataFrame([res["m_val"]]), ndigits)
318
- if "Validation_Summary" in selected and res.get("sv_val"):
319
- _add_sheet(sheets, order, "Validation_Summary", pd.DataFrame([res["sv_val"]]), ndigits)
320
- if "Validation_OOR" in selected and isinstance(res.get("oor_tbl"), pd.DataFrame) and not res["oor_tbl"].empty:
321
- _add_sheet(sheets, order, "Validation_OOR", res["oor_tbl"].reset_index(drop=True), ndigits)
322
-
323
- # Prediction
324
- if "Prediction" in selected and "PredictOnly" in res:
325
- _add_sheet(sheets, order, "Prediction", res["PredictOnly"], ndigits)
326
- if "Prediction_Summary" in selected and res.get("sv_pred"):
327
- _add_sheet(sheets, order, "Prediction_Summary", pd.DataFrame([res["sv_pred"]]), ndigits)
328
-
329
- # Training ranges
330
- if "Training_Ranges" in selected and st.session_state.get("train_ranges"):
331
- rr = _train_ranges_df(st.session_state["train_ranges"])
332
- _add_sheet(sheets, order, "Training_Ranges", rr, ndigits)
333
-
334
- # Info
335
- if "Info" in selected:
336
- info = pd.DataFrame([
337
- {"Key": "AppName", "Value": APP_NAME},
338
- {"Key": "Tagline", "Value": TAGLINE},
339
- {"Key": "Target", "Value": TARGET},
340
- {"Key": "PredColumn", "Value": PRED_COL},
341
- {"Key": "Features", "Value": ", ".join(FEATURES)},
342
- {"Key": "ExportedAt", "Value": datetime.now().strftime("%Y-%m-%d %H:%M:%S")},
343
- ])
344
- _add_sheet(sheets, order, "Info", info, ndigits)
345
-
346
- if not order:
347
- return None, None, []
348
-
349
- bio = io.BytesIO()
350
- engine = _excel_engine()
351
- with pd.ExcelWriter(bio, engine=engine) as writer:
352
- for name in order:
353
- df = sheets[name]
354
- sheet = _excel_safe_name(name)
355
- df.to_excel(writer, sheet_name=sheet, index=False)
356
- _excel_autofit(writer, sheet, df)
357
- bio.seek(0)
358
-
359
- fname = f"YM_Export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
360
- return bio.getvalue(), fname, order
361
-
362
  def render_export_button(phase_key: str, default_sections: list[str]) -> None:
363
- """Export UI — dropdown checklist, fixed rounding=3, appears inside each phase after results."""
364
  st.divider()
365
  st.markdown("### Export to Excel")
366
 
 
367
  all_sections = [
368
  "Training","Training_Metrics","Training_Summary",
369
  "Testing","Testing_Metrics","Testing_Summary",
@@ -371,626 +11,45 @@ def render_export_button(phase_key: str, default_sections: list[str]) -> None:
371
  "Prediction","Prediction_Summary",
372
  "Training_Ranges","Info"
373
  ]
374
- # Dropdown checklist
375
- selected = st.multiselect(
376
  "Sheets to include",
377
  options=all_sections,
378
  default=default_sections,
379
- help="Choose which sheets to include in the Excel export.",
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  )
 
 
381
 
382
  base_name = st.text_input("Base filename", value="YM_Export", key=f"basename_{phase_key}")
383
 
384
- data, _, names = build_export_workbook(selected=selected, ndigits=3) # fixed 3 decimals
 
 
385
  if names:
386
  st.caption("Will include: " + ", ".join(names))
387
 
 
 
 
 
388
  st.download_button(
389
  "⬇️ Export Excel",
390
  data=(data or b""),
391
- file_name=((base_name or "YM_Export") + "_" + datetime.now().strftime("%Y%m%d_%H%M%S") + ".xlsx") if data else "YM_Export.xlsx",
392
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
393
  disabled=(data is None),
394
  key=f"download_{phase_key}",
395
  )
396
-
397
- # =========================
398
- # Cross plot (Matplotlib) — auto-scaled for Ym
399
- # =========================
400
- def cross_plot_static(actual, pred, xlabel="Actual Ym", ylabel="Predicted Ym"):
401
- a = pd.Series(actual, dtype=float)
402
- p = pd.Series(pred, dtype=float)
403
-
404
- lo = float(min(a.min(), p.min()))
405
- hi = float(max(a.max(), p.max()))
406
- pad = 0.03 * (hi - lo if hi > lo else 1.0)
407
- lo2, hi2 = lo - pad, hi + pad
408
-
409
- ticks = np.linspace(lo2, hi2, 5)
410
-
411
- dpi = 110
412
- fig, ax = plt.subplots(figsize=(CROSS_W / dpi, CROSS_H / dpi), dpi=dpi, constrained_layout=False)
413
-
414
- ax.scatter(a, p, s=14, c=COLORS["pred"], alpha=0.9, linewidths=0)
415
- ax.plot([lo2, hi2], [lo2, hi2], linestyle="--", linewidth=1.2, color=COLORS["ref"])
416
-
417
- ax.set_xlim(lo2, hi2)
418
- ax.set_ylim(lo2, hi2)
419
- ax.set_xticks(ticks)
420
- ax.set_yticks(ticks)
421
- ax.set_aspect("equal", adjustable="box")
422
-
423
- fmt = FuncFormatter(lambda x, _: f"{x:.2f}")
424
- ax.xaxis.set_major_formatter(fmt)
425
- ax.yaxis.set_major_formatter(fmt)
426
-
427
- ax.set_xlabel(xlabel, fontweight="bold", fontsize=10, color="black")
428
- ax.set_ylabel(ylabel, fontweight="bold", fontsize=10, color="black")
429
- ax.tick_params(labelsize=6, colors="black")
430
-
431
- ax.grid(True, linestyle=":", alpha=0.3)
432
- for spine in ax.spines.values():
433
- spine.set_linewidth(1.1)
434
- spine.set_color("#444")
435
-
436
- fig.subplots_adjust(left=0.16, bottom=0.16, right=0.98, top=0.98)
437
- return fig
438
-
439
- # =========================
440
- # Track plot (Plotly) — x axis with NO decimals
441
- # =========================
442
- def track_plot(df, include_actual=True):
443
- # Depth (or index) on Y
444
- depth_col = next((c for c in df.columns if 'depth' in str(c).lower()), None)
445
- if depth_col is not None:
446
- y = pd.Series(df[depth_col]).astype(float)
447
- ylab = depth_col
448
- y_range = [float(y.max()), float(y.min())] # reverse
449
- else:
450
- y = pd.Series(np.arange(1, len(df) + 1))
451
- ylab = "Point Index"
452
- y_range = [float(y.max()), float(y.min())]
453
-
454
- # X range from prediction/actual
455
- x_series = pd.Series(df.get(PRED_COL, pd.Series(dtype=float))).astype(float)
456
- if include_actual and TARGET in df.columns:
457
- x_series = pd.concat([x_series, pd.Series(df[TARGET]).astype(float)], ignore_index=True)
458
- x_lo, x_hi = float(x_series.min()), float(x_series.max())
459
- x_pad = 0.03 * (x_hi - x_lo if x_hi > x_lo else 1.0)
460
- xmin, xmax = x_lo - x_pad, x_hi + x_pad
461
- tick0 = _nice_tick0(xmin, step=max((xmax - xmin) / 10.0, 0.1))
462
-
463
- fig = go.Figure()
464
- if PRED_COL in df.columns:
465
- fig.add_trace(go.Scatter(
466
- x=df[PRED_COL], y=y, mode="lines",
467
- line=dict(color=COLORS["pred"], width=1.8),
468
- name=PRED_COL,
469
- hovertemplate=f"{PRED_COL}: "+"%{x:.0f}<br>"+ylab+": %{y}<extra></extra>"
470
- ))
471
- if include_actual and TARGET in df.columns:
472
- fig.add_trace(go.Scatter(
473
- x=df[TARGET], y=y, mode="lines",
474
- line=dict(color=COLORS["actual"], width=2.0, dash="dot"),
475
- name=f"{TARGET} (actual)",
476
- hovertemplate=f"{TARGET}: "+"%{x:.0f}<br>"+ylab+": %{y}<extra></extra>"
477
- ))
478
-
479
- fig.update_layout(
480
- height=TRACK_H, width=TRACK_W, autosize=False,
481
- paper_bgcolor="#fff", plot_bgcolor="#fff",
482
- margin=dict(l=64, r=16, t=36, b=48), hovermode="closest",
483
- font=dict(size=FONT_SZ, color="#000"),
484
- legend=dict(x=0.98, y=0.05, xanchor="right", yanchor="bottom",
485
- bgcolor="rgba(255,255,255,0.75)", bordercolor="#ccc", borderwidth=1),
486
- legend_title_text=""
487
- )
488
- fig.update_xaxes(
489
- title_text="Ym",
490
- title_font=dict(size=20, family=BOLD_FONT, color="#000"),
491
- tickfont=dict(size=15, family=BOLD_FONT, color="#000"),
492
- side="top", range=[xmin, xmax],
493
- ticks="outside", tickformat=",.0f", tickmode="auto", tick0=tick0,
494
- showline=True, linewidth=1.2, linecolor="#444", mirror=True,
495
- showgrid=True, gridcolor="rgba(0,0,0,0.12)", automargin=True
496
- )
497
- fig.update_yaxes(
498
- title_text=ylab,
499
- title_font=dict(size=20, family=BOLD_FONT, color="#000"),
500
- tickfont=dict(size=15, family=BOLD_FONT, color="#000"),
501
- range=y_range, ticks="outside",
502
- showline=True, linewidth=1.2, linecolor="#444", mirror=True,
503
- showgrid=True, gridcolor="rgba(0,0,0,0.12)", automargin=True
504
- )
505
-
506
- return fig
507
-
508
- # ---------- Preview modal (matplotlib) ----------
509
- def preview_tracks(df: pd.DataFrame, cols: list[str]):
510
- cols = [c for c in cols if c in df.columns]
511
- n = len(cols)
512
- if n == 0:
513
- fig, ax = plt.subplots(figsize=(4, 2))
514
- ax.text(0.5,0.5,"No selected columns",ha="center",va="center"); ax.axis("off")
515
- return fig
516
- fig, axes = plt.subplots(1, n, figsize=(2.2*n, 7.0), sharey=True, dpi=100)
517
- if n == 1:
518
- axes = [axes]
519
- idx = np.arange(1, len(df) + 1)
520
- for ax, col in zip(axes, cols):
521
- ax.plot(pd.to_numeric(df[col], errors="coerce"), idx, '-', lw=1.4, color="#333")
522
- ax.set_xlabel(col); ax.xaxis.set_label_position('top'); ax.xaxis.tick_top(); ax.invert_yaxis()
523
- ax.grid(True, linestyle=":", alpha=0.3)
524
- for s in ax.spines.values(): s.set_visible(True)
525
- axes[0].set_ylabel("Point Index")
526
- return fig
527
-
528
- # Modal wrapper (Streamlit compatibility)
529
- try:
530
- dialog = st.dialog
531
- except AttributeError:
532
- def dialog(title):
533
- def deco(fn):
534
- def wrapper(*args, **kwargs):
535
- with st.expander(title, expanded=True):
536
- return fn(*args, **kwargs)
537
- return wrapper
538
- return deco
539
-
540
- def preview_modal(book: dict[str, pd.DataFrame]):
541
- if not book:
542
- st.info("No data loaded yet."); return
543
- names = list(book.keys())
544
- tabs = st.tabs(names)
545
- for t, name in zip(tabs, names):
546
- with t:
547
- df = book[name]
548
- t1, t2 = st.tabs(["Tracks", "Summary"])
549
- with t1:
550
- st.pyplot(preview_tracks(df, FEATURES), use_container_width=True)
551
- with t2:
552
- feat_present = [c for c in FEATURES if c in df.columns]
553
- if not feat_present:
554
- st.info("No feature columns found to summarize.")
555
- else:
556
- tbl = (
557
- df[feat_present]
558
- .agg(['min','max','mean','std'])
559
- .T.rename(columns={"min":"Min","max":"Max","mean":"Mean","std":"Std"})
560
- .reset_index(names="Feature")
561
- )
562
- df_centered_rounded(tbl)
563
-
564
- # =========================
565
- # Load model + meta
566
- # =========================
567
- def ensure_model() -> Path|None:
568
- for p in [DEFAULT_MODEL, *MODEL_FALLBACKS]:
569
- if p.exists() and p.stat().st_size > 0:
570
- return p
571
- url = os.environ.get("MODEL_URL", "")
572
- if not url:
573
- return None
574
- try:
575
- import requests
576
- DEFAULT_MODEL.parent.mkdir(parents=True, exist_ok=True)
577
- with requests.get(url, stream=True, timeout=30) as r:
578
- r.raise_for_status()
579
- with open(DEFAULT_MODEL, "wb") as f:
580
- for chunk in r.iter_content(1<<20):
581
- if chunk:
582
- f.write(chunk)
583
- return DEFAULT_MODEL
584
- except Exception:
585
- return None
586
-
587
- mpath = ensure_model()
588
- if not mpath:
589
- st.error("Model not found. Upload models/ym_rf.joblib (or set MODEL_URL).")
590
- st.stop()
591
- try:
592
- model = load_model(str(mpath))
593
- except Exception as e:
594
- st.error(f"Failed to load model: {e}")
595
- st.stop()
596
-
597
- # Load meta (optional): support models/meta.json or models/ym_meta.json
598
- meta = {}
599
- for cand in [MODELS_DIR / "meta.json", MODELS_DIR / "ym_meta.json"]:
600
- if cand.exists():
601
- try:
602
- meta = json.loads(cand.read_text(encoding="utf-8"))
603
- break
604
- except Exception:
605
- pass
606
-
607
- if meta:
608
- FEATURES = meta.get("features", FEATURES)
609
- TARGET = meta.get("target", TARGET)
610
-
611
- # Warn if runtime != training versions
612
- try:
613
- import numpy as _np, sklearn as _skl
614
- mv = meta.get("versions", {}) if isinstance(meta, dict) else {}
615
- msg = []
616
- if mv.get("numpy") and mv["numpy"] != _np.__version__:
617
- msg.append(f"NumPy {mv['numpy']} expected, running {_np.__version__}")
618
- if mv.get("scikit_learn") and mv["scikit_learn"] != _skl.__version__:
619
- msg.append(f"scikit-learn {mv['scikit_learn']} expected, running {_skl.__version__}")
620
- if msg:
621
- st.warning("Environment mismatch: " + " | ".join(msg))
622
- except Exception:
623
- pass
624
-
625
- # =========================
626
- # Session state
627
- # =========================
628
- st.session_state.setdefault("app_step", "intro")
629
- st.session_state.setdefault("results", {})
630
- st.session_state.setdefault("train_ranges", None)
631
- st.session_state.setdefault("dev_file_name","")
632
- st.session_state.setdefault("dev_file_bytes",b"")
633
- st.session_state.setdefault("dev_file_loaded",False)
634
- st.session_state.setdefault("dev_preview",False)
635
- st.session_state.setdefault("show_preview_modal", False)
636
-
637
- # =========================
638
- # Branding in Sidebar
639
- # =========================
640
- st.sidebar.markdown(f"""
641
- <div class="centered-container">
642
- <img src="{inline_logo('logo.png')}" style="width: 200px; height: auto; object-fit: contain;">
643
- <div style='font-weight:800;font-size:1.2rem;'>{APP_NAME}</div>
644
- <div style='color:#667085;'>{TAGLINE}</div>
645
- </div>
646
- """, unsafe_allow_html=True
647
- )
648
-
649
- # =========================
650
- # Reusable Sticky Header
651
- # =========================
652
- def sticky_header(title, message):
653
- st.markdown(
654
- f"""
655
- <style>
656
- .sticky-container {{
657
- position: sticky; top: 0; background-color: white; z-index: 100;
658
- padding-top: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee;
659
- }}
660
- </style>
661
- <div class="sticky-container">
662
- <h3>{title}</h3>
663
- <p>{message}</p>
664
- </div>
665
- """,
666
- unsafe_allow_html=True
667
- )
668
-
669
- # =========================
670
- # INTRO
671
- # =========================
672
- if st.session_state.app_step == "intro":
673
- st.header("Welcome!")
674
- st.markdown("This software is developed by *Smart Thinking AI-Solutions Team* to estimate Young's Modulus (Ym) from drilling data.")
675
- st.subheader("How It Works")
676
- st.markdown(
677
- "1) **Upload your data to build the case and preview the model performance.** \n"
678
- "2) Click **Run Model** to compute metrics and plots. \n"
679
- "3) **Proceed to Validation** (with actual Ym) or **Proceed to Prediction** (no Ym)."
680
- )
681
- if st.button("Start Showcase", type="primary"):
682
- st.session_state.app_step = "dev"; st.rerun()
683
-
684
- # =========================
685
- # CASE BUILDING
686
- # =========================
687
- if st.session_state.app_step == "dev":
688
- st.sidebar.header("Case Building")
689
- up = st.sidebar.file_uploader("Upload Your Data File", type=["xlsx","xls"])
690
- if up is not None:
691
- st.session_state.dev_file_bytes = up.getvalue()
692
- st.session_state.dev_file_name = up.name
693
- st.session_state.dev_file_loaded = True
694
- st.session_state.dev_preview = False
695
- if st.session_state.dev_file_loaded:
696
- tmp = read_book_bytes(st.session_state.dev_file_bytes)
697
- if tmp:
698
- df0 = next(iter(tmp.values()))
699
- st.sidebar.caption(f"**Data loaded:** {st.session_state.dev_file_name} • {df0.shape[0]} rows × {df0.shape[1]} cols")
700
-
701
- if st.sidebar.button("Preview data", use_container_width=True, disabled=not st.session_state.dev_file_loaded):
702
- st.session_state.show_preview_modal = True
703
- st.session_state.dev_preview = True
704
-
705
- run = st.sidebar.button("Run Model", type="primary", use_container_width=True)
706
- if st.sidebar.button("Proceed to Validation ▶", use_container_width=True):
707
- st.session_state.app_step="validate"; st.rerun()
708
- if st.sidebar.button("Proceed to Prediction ▶", use_container_width=True):
709
- st.session_state.app_step="predict"; st.rerun()
710
-
711
- if st.session_state.dev_file_loaded and st.session_state.dev_preview:
712
- sticky_header("Case Building", "Previewed ✓ — now click **Run Model**.")
713
- elif st.session_state.dev_file_loaded:
714
- sticky_header("Case Building", "📄 **Preview uploaded data** using the sidebar button, then click **Run Model**.")
715
- else:
716
- sticky_header("Case Building", "**Upload your data to build a case, then run the model to review development performance.**")
717
-
718
- if run and st.session_state.dev_file_bytes:
719
- book = read_book_bytes(st.session_state.dev_file_bytes)
720
- sh_train = find_sheet(book, ["Train","Training","training2","train","training"])
721
- sh_test = find_sheet(book, ["Test","Testing","testing2","test","testing"])
722
- if sh_train is None or sh_test is None:
723
- st.markdown('<div class="st-message-box st-error">Workbook must include Train/Training/training2 and Test/Testing/testing2 sheets.</div>', unsafe_allow_html=True)
724
- st.stop()
725
- tr = _normalize_columns(book[sh_train].copy())
726
- te = _normalize_columns(book[sh_test].copy())
727
-
728
- if not (ensure_cols(tr, FEATURES+[TARGET]) and ensure_cols(te, FEATURES+[TARGET])):
729
- st.markdown('<div class="st-message-box st-error">Missing required columns.</div>', unsafe_allow_html=True)
730
- st.stop()
731
-
732
- tr[PRED_COL] = model.predict(tr[FEATURES])
733
- te[PRED_COL] = model.predict(te[FEATURES])
734
-
735
- st.session_state.results["Train"]=tr; st.session_state.results["Test"]=te
736
- st.session_state.results["m_train"]={
737
- "R": pearson_r(tr[TARGET], tr[PRED_COL]),
738
- "RMSE": rmse(tr[TARGET], tr[PRED_COL]),
739
- "MAE": mean_absolute_error(tr[TARGET], tr[PRED_COL])
740
- }
741
- st.session_state.results["m_test"]={
742
- "R": pearson_r(te[TARGET], te[PRED_COL]),
743
- "RMSE": rmse(te[TARGET], te[PRED_COL]),
744
- "MAE": mean_absolute_error(te[TARGET], te[PRED_COL])
745
- }
746
-
747
- tr_min = tr[FEATURES].min().to_dict(); tr_max = tr[FEATURES].max().to_dict()
748
- st.session_state.train_ranges = {f:(float(tr_min[f]), float(tr_max[f])) for f in FEATURES}
749
- st.markdown('<div class="st-message-box st-success">Case has been built and results are displayed below.</div>', unsafe_allow_html=True)
750
-
751
- # --- Export (DEV phase) ---
752
- default_sections = [s for s in _available_sections() if s.startswith("Training") or s.startswith("Testing")] + ["Training_Ranges","Info"]
753
- render_export_button(phase_key="dev", default_sections=default_sections)
754
-
755
- def _dev_block(df, m):
756
- c1,c2,c3 = st.columns(3)
757
- c1.metric("R", f"{m['R']:.2f}")
758
- c2.metric("RMSE", f"{m['RMSE']:.2f}")
759
- c3.metric("MAE", f"{m['MAE']:.2f}")
760
-
761
- st.markdown("""
762
- <div style='text-align: left; font-size: 0.8em; color: #6b7280; margin-top: -16px; margin-bottom: 8px;'>
763
- <strong>R:</strong> Pearson Correlation Coefficient<br>
764
- <strong>RMSE:</strong> Root Mean Square Error<br>
765
- <strong>MAE:</strong> Mean Absolute Error
766
- </div>
767
- """, unsafe_allow_html=True)
768
-
769
- col_track, col_cross = st.columns([2, 3], gap="large")
770
- with col_track:
771
- st.plotly_chart(
772
- track_plot(df, include_actual=True),
773
- use_container_width=False,
774
- config={"displayModeBar": False, "scrollZoom": True}
775
- )
776
- with col_cross:
777
- st.pyplot(
778
- cross_plot_static(df[TARGET], df[PRED_COL]),
779
- use_container_width=False
780
- )
781
-
782
- if "Train" in st.session_state.results or "Test" in st.session_state.results:
783
- tab1, tab2 = st.tabs(["Training", "Testing"])
784
- if "Train" in st.session_state.results:
785
- with tab1: _dev_block(st.session_state.results["Train"], st.session_state.results["m_train"])
786
- if "Test" in st.session_state.results:
787
- with tab2: _dev_block(st.session_state.results["Test"], st.session_state.results["m_test"])
788
-
789
- # =========================
790
- # VALIDATION (with actual Ym)
791
- # =========================
792
- if st.session_state.app_step == "validate":
793
- st.sidebar.header("Validate the Model")
794
- up = st.sidebar.file_uploader("Upload Validation Excel", type=["xlsx","xls"])
795
- if up is not None:
796
- book = read_book_bytes(up.getvalue())
797
- if book:
798
- df0 = next(iter(book.values()))
799
- st.sidebar.caption(f"**Data loaded:** {up.name} • {df0.shape[0]} rows × {df0.shape[1]} cols")
800
- if st.sidebar.button("Preview data", use_container_width=True, disabled=(up is None)):
801
- st.session_state.show_preview_modal = True
802
- go_btn = st.sidebar.button("Predict & Validate", type="primary", use_container_width=True)
803
- if st.sidebar.button("⬅ Back to Case Building", use_container_width=True):
804
- st.session_state.app_step="dev"; st.rerun()
805
- if st.sidebar.button("Proceed to Prediction ▶", use_container_width=True):
806
- st.session_state.app_step="predict"; st.rerun()
807
-
808
- sticky_header("Validate the Model", "Upload a dataset with the same **features** and **Actual Ym** to evaluate performance.")
809
-
810
- if go_btn and up is not None:
811
- book = read_book_bytes(up.getvalue())
812
- name = find_sheet(book, ["Validation","Validate","validation2","Val","val"]) or list(book.keys())[0]
813
- df = _normalize_columns(book[name].copy())
814
- if not ensure_cols(df, FEATURES+[TARGET]):
815
- st.markdown('<div class="st-message-box st-error">Missing required columns.</div>', unsafe_allow_html=True); st.stop()
816
-
817
- df[PRED_COL] = model.predict(df[FEATURES])
818
- st.session_state.results["Validate"]=df
819
-
820
- ranges = st.session_state.train_ranges; oor_pct = 0.0; tbl=None
821
- if ranges:
822
- any_viol = pd.DataFrame({f:(df[f]<ranges[f][0])|(df[f]>ranges[f][1]) for f in FEATURES}).any(axis=1)
823
- oor_pct = float(any_viol.mean()*100.0)
824
- if any_viol.any():
825
- tbl = df.loc[any_viol, FEATURES].copy()
826
- for c in FEATURES:
827
- if pd.api.types.is_numeric_dtype(tbl[c]): tbl[c] = tbl[c].round(2)
828
- tbl["Violations"] = pd.DataFrame({f:(df[f]<ranges[f][0])|(df[f]>ranges[f][1]) for f in FEATURES}).loc[any_viol].apply(lambda r:", ".join([c for c,v in r.items() if v]), axis=1)
829
- st.session_state.results["m_val"]={
830
- "R": pearson_r(df[TARGET], df[PRED_COL]),
831
- "RMSE": rmse(df[TARGET], df[PRED_COL]),
832
- "MAE": mean_absolute_error(df[TARGET], df[PRED_COL])
833
- }
834
- st.session_state.results["sv_val"]={"n":len(df), "pred_min":float(df[PRED_COL].min()), "pred_max":float(df[PRED_COL].max()), "oor":oor_pct}
835
- st.session_state.results["oor_tbl"]=tbl
836
-
837
- # --- Export (VALIDATE phase) ---
838
- default_sections = ["Validation","Validation_Metrics","Validation_Summary"]
839
- if st.session_state.results.get("oor_tbl") is not None and not st.session_state.results["oor_tbl"].empty:
840
- default_sections.append("Validation_OOR")
841
- default_sections += ["Training_Ranges","Info"]
842
- render_export_button(phase_key="validate", default_sections=default_sections)
843
-
844
- if "Validate" in st.session_state.results:
845
- m = st.session_state.results["m_val"]
846
- c1,c2,c3 = st.columns(3)
847
- c1.metric("R", f"{m['R']:.2f}")
848
- c2.metric("RMSE", f"{m['RMSE']:.2f}")
849
- c3.metric("MAE", f"{m['MAE']:.2f}")
850
-
851
- st.markdown("""
852
- <div style='text-align: left; font-size: 0.8em; color: #6b7280; margin-top: -16px; margin-bottom: 8px;'>
853
- <strong>R:</strong> Pearson Correlation Coefficient<br>
854
- <strong>RMSE:</strong> Root Mean Square Error<br>
855
- <strong>MAE:</strong> Mean Absolute Error
856
- </div>
857
- """, unsafe_allow_html=True)
858
-
859
- col_track, col_cross = st.columns([2, 3], gap="large")
860
- with col_track:
861
- st.plotly_chart(
862
- track_plot(st.session_state.results["Validate"], include_actual=True),
863
- use_container_width=False,
864
- config={"displayModeBar": False, "scrollZoom": True}
865
- )
866
- with col_cross:
867
- st.pyplot(
868
- cross_plot_static(st.session_state.results["Validate"][TARGET],
869
- st.session_state.results["Validate"][PRED_COL]),
870
- use_container_width=False
871
- )
872
-
873
- sv = st.session_state.results["sv_val"]
874
- if sv["oor"] > 0:
875
- st.markdown('<div class="st-message-box st-warning">Some inputs fall outside **training min–max** ranges.</div>', unsafe_allow_html=True)
876
- if st.session_state.results["oor_tbl"] is not None:
877
- st.write("*Out-of-range rows (vs. Training min–max):*")
878
- df_centered_rounded(st.session_state.results["oor_tbl"])
879
-
880
- # =========================
881
- # PREDICTION (no actual Ym)
882
- # =========================
883
- if st.session_state.app_step == "predict":
884
- st.sidebar.header("Prediction (No Actual Ym)")
885
- up = st.sidebar.file_uploader("Upload Prediction Excel", type=["xlsx","xls"])
886
- if up is not None:
887
- book = read_book_bytes(up.getvalue())
888
- if book:
889
- df0 = next(iter(book.values()))
890
- st.sidebar.caption(f"**Data loaded:** {up.name} • {df0.shape[0]} rows × {df0.shape[1]} cols")
891
- if st.sidebar.button("Preview data", use_container_width=True, disabled=(up is None)):
892
- st.session_state.show_preview_modal = True
893
- go_btn = st.sidebar.button("Predict", type="primary", use_container_width=True)
894
- if st.sidebar.button("⬅ Back to Case Building", use_container_width=True):
895
- st.session_state.app_step="dev"; st.rerun()
896
-
897
- sticky_header("Prediction", "Upload a dataset with the feature columns (no **Actual Ym**).")
898
-
899
- if go_btn and up is not None:
900
- book = read_book_bytes(up.getvalue()); name = list(book.keys())[0]
901
- df = _normalize_columns(book[name].copy())
902
- if not ensure_cols(df, FEATURES):
903
- st.markdown('<div class="st-message-box st-error">Missing required columns.</div>', unsafe_allow_html=True); st.stop()
904
- df[PRED_COL] = model.predict(df[FEATURES])
905
- st.session_state.results["PredictOnly"]=df
906
-
907
- ranges = st.session_state.train_ranges; oor_pct = 0.0
908
- if ranges:
909
- any_viol = pd.DataFrame({f:(df[f]<ranges[f][0])|(df[f]>ranges[f][1]) for f in FEATURES}).any(axis=1)
910
- oor_pct = float(any_viol.mean()*100.0)
911
- st.session_state.results["sv_pred"]={
912
- "n":len(df),
913
- "pred_min":float(df[PRED_COL].min()),
914
- "pred_max":float(df[PRED_COL].max()),
915
- "pred_mean":float(df[PRED_COL].mean()),
916
- "pred_std":float(df[PRED_COL].std(ddof=0)),
917
- "oor":oor_pct
918
- }
919
-
920
- # --- Export (PREDICT phase) ---
921
- default_sections = ["Prediction","Prediction_Summary","Training_Ranges","Info"]
922
- render_export_button(phase_key="predict", default_sections=default_sections)
923
-
924
- if "PredictOnly" in st.session_state.results:
925
- df = st.session_state.results["PredictOnly"]; sv = st.session_state.results["sv_pred"]
926
-
927
- col_left, col_right = st.columns([2,3], gap="large")
928
- with col_left:
929
- table = pd.DataFrame({
930
- "Metric": ["# points","Pred min","Pred max","Pred mean","Pred std","OOR %"],
931
- "Value": [sv["n"],
932
- round(sv["pred_min"],2),
933
- round(sv["pred_max"],2),
934
- round(sv["pred_mean"],2),
935
- round(sv["pred_std"],2),
936
- f'{sv["oor"]:.1f}%']
937
- })
938
- st.markdown('<div class="st-message-box st-success">Predictions ready ✓</div>', unsafe_allow_html=True)
939
- df_centered_rounded(table, hide_index=True)
940
- st.caption("**★ OOR** = % of rows whose input features fall outside the training min–max range.")
941
- with col_right:
942
- st.plotly_chart(
943
- track_plot(df, include_actual=False),
944
- use_container_width=False,
945
- config={"displayModeBar": False, "scrollZoom": True}
946
- )
947
-
948
- # =========================
949
- # Preview modal (re-usable)
950
- # =========================
951
- if st.session_state.show_preview_modal:
952
- book_to_preview = {}
953
- if st.session_state.app_step == "dev":
954
- book_to_preview = read_book_bytes(st.session_state.dev_file_bytes)
955
- elif st.session_state.app_step in ["validate", "predict"] and 'up' in locals() and up is not None:
956
- book_to_preview = read_book_bytes(up.getvalue())
957
-
958
- with st.expander("Preview data", expanded=True):
959
- if not book_to_preview:
960
- st.markdown('<div class="st-message-box">No data loaded yet.</div>', unsafe_allow_html=True)
961
- else:
962
- names = list(book_to_preview.keys())
963
- tabs = st.tabs(names)
964
- for t, name in zip(tabs, names):
965
- with t:
966
- df = _normalize_columns(book_to_preview[name])
967
- t1, t2 = st.tabs(["Tracks", "Summary"])
968
- with t1:
969
- st.pyplot(preview_tracks(df, FEATURES), use_container_width=True)
970
- with t2:
971
- feat_present = [c for c in FEATURES if c in df.columns]
972
- if not feat_present:
973
- st.info("No feature columns found to summarize.")
974
- else:
975
- tbl = (
976
- df[feat_present]
977
- .agg(['min','max','mean','std'])
978
- .T.rename(columns={"min":"Min","max":"Max","mean":"Mean","std":"Std"})
979
- .reset_index(names="Feature")
980
- )
981
- df_centered_rounded(tbl)
982
- st.session_state.show_preview_modal = False
983
-
984
- # (Removed the old global bottom-of-page export — now shown per phase)
985
-
986
- # =========================
987
- # Footer
988
- # =========================
989
- st.markdown("""
990
- <br><br><br>
991
- <hr>
992
- <div style='text-align:center;color:#6b7280;font-size:1.0em;'>
993
- © 2025 Smart Thinking AI-Solutions Team. All rights reserved.<br>
994
- Website: <a href="https://smartthinking.com.sa" target="_blank" rel="noopener noreferrer">smartthinking.com.sa</a>
995
- </div>
996
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  def render_export_button(phase_key: str, default_sections: list[str]) -> None:
2
+ """Export UI — dropdown checklists for sheets & options; fixed rounding=3."""
3
  st.divider()
4
  st.markdown("### Export to Excel")
5
 
6
+ # ---- Dropdown checklist: sheets ----
7
  all_sections = [
8
  "Training","Training_Metrics","Training_Summary",
9
  "Testing","Testing_Metrics","Testing_Summary",
 
11
  "Prediction","Prediction_Summary",
12
  "Training_Ranges","Info"
13
  ]
14
+ selected_sheets = st.multiselect(
 
15
  "Sheets to include",
16
  options=all_sections,
17
  default=default_sections,
18
+ help="Pick the sheets to include in the Excel export.",
19
+ key=f"sheets_{phase_key}",
20
+ )
21
+
22
+ # ---- Dropdown checklist: export options ----
23
+ export_options = st.multiselect(
24
+ "Export options",
25
+ options=[
26
+ "Auto-fit columns & freeze header",
27
+ "Append timestamp to filename",
28
+ ],
29
+ default=["Auto-fit columns & freeze header", "Append timestamp to filename"],
30
+ help="Choose extra export behaviors.",
31
+ key=f"opts_{phase_key}",
32
  )
33
+ do_autofit = "Auto-fit columns & freeze header" in export_options
34
+ add_timestamp = "Append timestamp to filename" in export_options
35
 
36
  base_name = st.text_input("Base filename", value="YM_Export", key=f"basename_{phase_key}")
37
 
38
+ # Build workbook (fixed rounding=3) and optionally auto-fit columns
39
+ data, _, names = build_export_workbook(selected=selected_sheets, ndigits=3, do_autofit=do_autofit)
40
+
41
  if names:
42
  st.caption("Will include: " + ", ".join(names))
43
 
44
+ # Compose filename
45
+ suffix = "_" + datetime.now().strftime("%Y%m%d_%H%M%S") if add_timestamp else ""
46
+ file_name = (base_name or "YM_Export") + suffix + ".xlsx"
47
+
48
  st.download_button(
49
  "⬇️ Export Excel",
50
  data=(data or b""),
51
+ file_name=file_name if data else "YM_Export.xlsx",
52
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
53
  disabled=(data is None),
54
  key=f"download_{phase_key}",
55
  )