UCS2014 commited on
Commit
fa32f46
·
verified ·
1 Parent(s): 074f655

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +57 -73
app.py CHANGED
@@ -5,11 +5,11 @@ import pandas as pd
5
  import numpy as np
6
  import joblib
7
  import matplotlib
8
- matplotlib.use("Agg") # fallback only
9
  import matplotlib.pyplot as plt
10
  from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
11
 
12
- # Try Plotly for interactivity
13
  HAVE_PLOTLY = True
14
  try:
15
  import plotly.graph_objects as go
@@ -17,20 +17,16 @@ try:
17
  except Exception:
18
  HAVE_PLOTLY = False
19
 
20
- # =========================
21
- # Defaults
22
- # =========================
23
  FEATURES = ["Q, gpm", "SPP(psi)", "T (kft.lbf)", "WOB (klbf)", "ROP (ft/h)"]
24
  TARGET = "UCS"
25
  MODELS_DIR = Path("models")
26
  DEFAULT_MODEL = MODELS_DIR / "ucs_rf.joblib"
27
  MODEL_FALLBACKS = [MODELS_DIR / "model.joblib", MODELS_DIR / "model.pkl"]
28
 
29
- COLORS = {"pred": "#1f77b4", "actual": "#f2b702", "ref": "#5a5a5a", "orange": "#f59e0b", "green": "#198754"}
30
 
31
- # =========================
32
- # Page / Theme
33
- # =========================
34
  st.set_page_config(page_title="ST_GeoMech_UCS", page_icon="logo.png", layout="wide")
35
  st.markdown("<style>header, footer{visibility:hidden !important;}</style>", unsafe_allow_html=True)
36
  st.markdown(
@@ -39,35 +35,36 @@ st.markdown(
39
  .stApp { background: #FFFFFF; }
40
  section[data-testid="stSidebar"] { background: #F6F9FC; }
41
  .block-container { padding-top: .5rem; padding-bottom: .5rem; }
42
- .stButton>button{ background:#0d6efd; color:#fff; font-weight:bold; border-radius:8px; border:none; padding:10px 24px; }
 
 
 
 
43
  .stButton>button:hover{ filter: brightness(0.92); }
44
 
45
- /* Hero header */
46
  .st-hero { display:flex; align-items:center; gap:16px; padding-top: 4px; }
47
  .st-hero .brand { width:110px; height:110px; object-fit:contain; }
48
  .st-hero h1 { margin:0; line-height:1.05; }
49
  .st-hero .tagline { margin:2px 0 0 2px; color:#6b7280; font-size:1.05rem; font-style:italic; }
50
  [data-testid="stBlock"]{ margin-top:0 !important; }
51
 
52
- /* Colorize sidebar button groups we wrap with custom classes */
53
- section[data-testid="stSidebar"] .dev-actions .stButton:nth-of-type(1) button { background: #f59e0b !important; } /* Preview - orange */
54
- section[data-testid="stSidebar"] .dev-actions .stButton:nth-of-type(2) button { background: #0d6efd !important; } /* Run - blue */
55
- section[data-testid="stSidebar"] .dev-actions .stButton:nth-of-type(3) button { background: #198754 !important; } /* Proceed - green */
56
 
57
- section[data-testid="stSidebar"] .val-actions .stButton:nth-of-type(1) button { background: #f59e0b !important; } /* Preview - orange */
58
- section[data-testid="stSidebar"] .val-actions .stButton:nth-of-type(2) button { background: #0d6efd !important; } /* Predict - blue */
59
 
60
- /* Disabled look */
61
- section[data-testid="stSidebar"] .dev-actions .stButton button:disabled,
62
- section[data-testid="stSidebar"] .val-actions .stButton button:disabled { filter: grayscale(40%); opacity:.6; }
63
  </style>
64
  """,
65
  unsafe_allow_html=True
66
  )
67
 
68
- # =========================
69
- # Helpers
70
- # =========================
71
  try:
72
  dialog = st.dialog
73
  except AttributeError:
@@ -132,7 +129,20 @@ def inline_logo(path="logo.png") -> str:
132
  except Exception:
133
  return ""
134
 
135
- # -------- Plotting (Plotly first, Matplotlib fallback) --------
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def cross_plotly(actual, pred, title):
137
  lo = float(np.nanmin([actual.min(), pred.min()]))
138
  hi = float(np.nanmax([actual.max(), pred.max()]))
@@ -149,10 +159,8 @@ def cross_plotly(actual, pred, title):
149
  mode="lines", line=dict(dash="dash", width=1.5, color=COLORS["ref"]),
150
  hoverinfo="skip", showlegend=False
151
  ))
152
- fig.update_layout(
153
- title=title, margin=dict(l=10, r=10, t=40, b=10), height=350,
154
- legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0)
155
- )
156
  fig.update_xaxes(title_text="Actual UCS", scaleanchor="y", scaleratio=1)
157
  fig.update_yaxes(title_text="Predicted UCS")
158
  return fig
@@ -160,11 +168,9 @@ def cross_plotly(actual, pred, title):
160
  def track_plotly(df, include_actual=True):
161
  depth_col = next((c for c in df.columns if 'depth' in str(c).lower()), None)
162
  if depth_col is not None:
163
- y = df[depth_col]
164
- y_label = depth_col
165
  else:
166
- y = np.arange(1, len(df) + 1)
167
- y_label = "Point Index"
168
  fig = go.Figure()
169
  fig.add_trace(go.Scatter(
170
  x=df["UCS_Pred"], y=y, mode="lines",
@@ -181,10 +187,8 @@ def track_plotly(df, include_actual=True):
181
  ))
182
  fig.update_yaxes(autorange="reversed", title_text=y_label)
183
  fig.update_xaxes(title_text="UCS", side="top")
184
- fig.update_layout(
185
- margin=dict(l=10, r=10, t=40, b=10), height=650,
186
- legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0)
187
- )
188
  return fig
189
 
190
  def make_index_tracks_plotly(df: pd.DataFrame, cols: list[str]):
@@ -196,12 +200,13 @@ def make_index_tracks_plotly(df: pd.DataFrame, cols: list[str]):
196
  fig.update_layout(height=200, margin=dict(l=10,r=10,t=10,b=10))
197
  return fig
198
  n = len(cols)
199
- fig = make_subplots(rows=1, cols=n, shared_y=True, horizontal_spacing=0.05)
200
  idx = np.arange(1, len(df) + 1)
201
  for i, col in enumerate(cols, start=1):
202
  fig.add_trace(
203
  go.Scatter(x=df[col], y=idx, mode="lines", line=dict(color="#333", width=1.2),
204
- hovertemplate=f"{col}: "+"%{x:.2f}<br>Index: %{y}<extra></extra>", name=col, showlegend=False),
 
205
  row=1, col=i
206
  )
207
  fig.update_xaxes(title_text=col, side="top", row=1, col=i)
@@ -209,7 +214,7 @@ def make_index_tracks_plotly(df: pd.DataFrame, cols: list[str]):
209
  fig.update_layout(height=650, margin=dict(l=10, r=10, t=40, b=10))
210
  return fig
211
 
212
- # Fallbacks (kept if Plotly missing)
213
  def cross_plot_mpl(actual, pred, title, size=(3.9,3.9)):
214
  fig, ax = plt.subplots(figsize=size, dpi=100)
215
  ax.scatter(actual, pred, s=14, alpha=0.85, color=COLORS["pred"])
@@ -239,7 +244,6 @@ def depth_or_index_track_mpl(df, title=None, include_actual=True):
239
  ax.legend(loc="best")
240
  return fig
241
 
242
- # ---------- Preview modal helpers ----------
243
  def stats_table(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
244
  cols = [c for c in cols if c in df.columns]
245
  if not cols:
@@ -248,6 +252,7 @@ def stats_table(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
248
  out = out.rename(columns={"min": "Min", "max": "Max", "mean": "Mean", "std": "Std"})
249
  return out.reset_index().rename(columns={"index": "Feature"})
250
 
 
251
  @dialog("Preview data")
252
  def preview_modal_dev(book: dict[str, pd.DataFrame], feature_cols: list[str]):
253
  if not book:
@@ -288,11 +293,8 @@ def preview_modal_val(book: dict[str, pd.DataFrame], feature_cols: list[str]):
288
  with t2:
289
  st.dataframe(stats_table(df, feature_cols), use_container_width=True)
290
 
291
- # =========================
292
- # Model presence
293
- # =========================
294
  MODEL_URL = _get_model_url()
295
-
296
  def ensure_model_present() -> Path:
297
  for p in [DEFAULT_MODEL, *MODEL_FALLBACKS]:
298
  if p.exists() and p.stat().st_size > 0:
@@ -334,23 +336,19 @@ else:
334
  infer = infer_features_from_model(model)
335
  if infer: FEATURES = infer
336
 
337
- # =========================
338
- # Session state
339
- # =========================
340
  if "app_step" not in st.session_state: st.session_state.app_step = "intro"
341
  if "results" not in st.session_state: st.session_state.results = {}
342
  if "train_ranges" not in st.session_state: st.session_state.train_ranges = None
343
 
344
  for k, v in {
345
  "dev_ready": False, "dev_file_loaded": False, "dev_previewed": False,
346
- "dev_file_signature": None, "dev_preview_request": False,
347
- "dev_file_bytes": b"", "dev_file_name": "", "dev_file_rows": 0, "dev_file_cols": 0,
348
  }.items():
349
  if k not in st.session_state: st.session_state[k] = v
350
 
351
- # =========================
352
- # Hero header
353
- # =========================
354
  st.markdown(
355
  f"""
356
  <div class="st-hero">
@@ -361,12 +359,10 @@ st.markdown(
361
  </div>
362
  </div>
363
  """,
364
- unsafe_allow_html=True,
365
  )
366
 
367
- # =========================
368
- # INTRO PAGE
369
- # =========================
370
  if st.session_state.app_step == "intro":
371
  st.header("Welcome!")
372
  st.markdown("This software is developed by *Smart Thinking AI-Solutions Team* to estimate UCS from drilling data.")
@@ -388,20 +384,15 @@ if st.session_state.app_step == "intro":
388
  if st.button("Start Showcase", type="primary", key="start_showcase"):
389
  st.session_state.app_step = "dev"; st.rerun()
390
 
391
- # =========================
392
- # MODEL DEVELOPMENT
393
- # =========================
394
  if st.session_state.app_step == "dev":
395
  st.sidebar.header("Model Development Data")
396
  dev_label = "Upload Data (Excel)" if not st.session_state.dev_file_name else "Replace data (Excel)"
397
  train_test_file = st.sidebar.file_uploader(dev_label, type=["xlsx","xls"], key="dev_upload")
398
 
399
- # Persist upload
400
  if train_test_file is not None:
401
- try:
402
- file_bytes = train_test_file.getvalue(); size = len(file_bytes)
403
- except Exception:
404
- file_bytes = b""; size = 0
405
  sig = (train_test_file.name, size)
406
  if sig != st.session_state.dev_file_signature and size > 0:
407
  st.session_state.dev_file_signature = sig
@@ -422,7 +413,6 @@ if st.session_state.app_step == "dev":
422
  f"{st.session_state.dev_file_rows} rows × {st.session_state.dev_file_cols} cols"
423
  )
424
 
425
- # Button group with wrapper to color via CSS
426
  st.sidebar.markdown('<div class="dev-actions">', unsafe_allow_html=True)
427
  preview_btn = st.sidebar.button("Preview data", use_container_width=True, disabled=not st.session_state.dev_file_loaded)
428
  run_btn = st.sidebar.button("Run Model", type="primary", use_container_width=True)
@@ -432,7 +422,6 @@ if st.session_state.app_step == "dev":
432
  if proceed_clicked and st.session_state.dev_ready:
433
  st.session_state.app_step = "predict"; st.rerun()
434
 
435
- # Pinned helper
436
  helper_top = st.container()
437
  with helper_top:
438
  st.subheader("Model Development")
@@ -486,7 +475,6 @@ if st.session_state.app_step == "dev":
486
  status.update(label="Done ✓", state="complete"); toast("Model run complete 🚀")
487
  st.rerun()
488
 
489
- # Results
490
  if ("Train" in st.session_state.results) or ("Test" in st.session_state.results):
491
  tab1, tab2 = st.tabs(["Training", "Testing"])
492
  if "Train" in st.session_state.results:
@@ -545,9 +533,7 @@ if st.session_state.app_step == "dev":
545
  except RuntimeError as e:
546
  st.warning(str(e))
547
 
548
- # =========================
549
- # PREDICTION (Validation)
550
- # =========================
551
  if st.session_state.app_step == "predict":
552
  st.sidebar.header("Prediction (Validation)")
553
  validation_file = st.sidebar.file_uploader("Upload Validation Excel", type=["xlsx","xls"], key="val_upload")
@@ -664,9 +650,7 @@ if st.session_state.app_step == "predict":
664
  except RuntimeError as e:
665
  st.warning(str(e))
666
 
667
- # =========================
668
- # Footer
669
- # =========================
670
  st.markdown("---")
671
  st.markdown(
672
  """
 
5
  import numpy as np
6
  import joblib
7
  import matplotlib
8
+ matplotlib.use("Agg")
9
  import matplotlib.pyplot as plt
10
  from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
11
 
12
+ # Plotly (for interactivity)
13
  HAVE_PLOTLY = True
14
  try:
15
  import plotly.graph_objects as go
 
17
  except Exception:
18
  HAVE_PLOTLY = False
19
 
20
+ # ---------------- Defaults ----------------
 
 
21
  FEATURES = ["Q, gpm", "SPP(psi)", "T (kft.lbf)", "WOB (klbf)", "ROP (ft/h)"]
22
  TARGET = "UCS"
23
  MODELS_DIR = Path("models")
24
  DEFAULT_MODEL = MODELS_DIR / "ucs_rf.joblib"
25
  MODEL_FALLBACKS = [MODELS_DIR / "model.joblib", MODELS_DIR / "model.pkl"]
26
 
27
+ COLORS = {"pred": "#1f77b4", "actual": "#f2b702", "ref": "#5a5a5a"}
28
 
29
+ # ---------------- Page / Theme ----------------
 
 
30
  st.set_page_config(page_title="ST_GeoMech_UCS", page_icon="logo.png", layout="wide")
31
  st.markdown("<style>header, footer{visibility:hidden !important;}</style>", unsafe_allow_html=True)
32
  st.markdown(
 
35
  .stApp { background: #FFFFFF; }
36
  section[data-testid="stSidebar"] { background: #F6F9FC; }
37
  .block-container { padding-top: .5rem; padding-bottom: .5rem; }
38
+
39
+ /* Default Streamlit button style (Run, Predict remain blue) */
40
+ .stButton>button{
41
+ background:#0d6efd; color:#fff; font-weight:bold; border-radius:8px; border:none; padding:10px 24px;
42
+ }
43
  .stButton>button:hover{ filter: brightness(0.92); }
44
 
45
+ /* Hero */
46
  .st-hero { display:flex; align-items:center; gap:16px; padding-top: 4px; }
47
  .st-hero .brand { width:110px; height:110px; object-fit:contain; }
48
  .st-hero h1 { margin:0; line-height:1.05; }
49
  .st-hero .tagline { margin:2px 0 0 2px; color:#6b7280; font-size:1.05rem; font-style:italic; }
50
  [data-testid="stBlock"]{ margin-top:0 !important; }
51
 
52
+ /* Color the sidebar buttons by order inside our wrappers */
53
+ .dev-actions > div.stButton:nth-child(1) button { background:#f59e0b !important; } /* Preview (orange) */
54
+ .dev-actions > div.stButton:nth-child(2) button { background:#0d6efd !important; } /* Run (blue) */
55
+ .dev-actions > div.stButton:nth-child(3) button { background:#198754 !important; } /* Proceed (green) */
56
 
57
+ .val-actions > div.stButton:nth-child(1) button { background:#f59e0b !important; } /* Preview (orange) */
58
+ .val-actions > div.stButton:nth-child(2) button { background:#0d6efd !important; } /* Predict (blue) */
59
 
60
+ .dev-actions .stButton button:disabled,
61
+ .val-actions .stButton button:disabled{ filter: grayscale(40%); opacity:.6; }
 
62
  </style>
63
  """,
64
  unsafe_allow_html=True
65
  )
66
 
67
+ # ---------------- Helpers ----------------
 
 
68
  try:
69
  dialog = st.dialog
70
  except AttributeError:
 
129
  except Exception:
130
  return ""
131
 
132
+ def export_workbook(sheets_dict: dict[str, pd.DataFrame], summary_df: pd.DataFrame|None):
133
+ try:
134
+ import openpyxl # noqa
135
+ except Exception:
136
+ raise RuntimeError("Export requires openpyxl. Please add it to requirements.txt")
137
+ buf = io.BytesIO()
138
+ with pd.ExcelWriter(buf, engine="openpyxl") as xw:
139
+ for name, frame in sheets_dict.items():
140
+ frame.to_excel(xw, sheet_name=name[:31], index=False)
141
+ if summary_df is not None:
142
+ summary_df.to_excel(xw, sheet_name="Summary", index=False)
143
+ return buf.getvalue()
144
+
145
+ # ---------- Plotting (Plotly first, MPL fallback) ----------
146
  def cross_plotly(actual, pred, title):
147
  lo = float(np.nanmin([actual.min(), pred.min()]))
148
  hi = float(np.nanmax([actual.max(), pred.max()]))
 
159
  mode="lines", line=dict(dash="dash", width=1.5, color=COLORS["ref"]),
160
  hoverinfo="skip", showlegend=False
161
  ))
162
+ fig.update_layout(title=title, margin=dict(l=10, r=10, t=40, b=10), height=350,
163
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0))
 
 
164
  fig.update_xaxes(title_text="Actual UCS", scaleanchor="y", scaleratio=1)
165
  fig.update_yaxes(title_text="Predicted UCS")
166
  return fig
 
168
  def track_plotly(df, include_actual=True):
169
  depth_col = next((c for c in df.columns if 'depth' in str(c).lower()), None)
170
  if depth_col is not None:
171
+ y = df[depth_col]; y_label = depth_col
 
172
  else:
173
+ y = np.arange(1, len(df) + 1); y_label = "Point Index"
 
174
  fig = go.Figure()
175
  fig.add_trace(go.Scatter(
176
  x=df["UCS_Pred"], y=y, mode="lines",
 
187
  ))
188
  fig.update_yaxes(autorange="reversed", title_text=y_label)
189
  fig.update_xaxes(title_text="UCS", side="top")
190
+ fig.update_layout(margin=dict(l=10, r=10, t=40, b=10), height=650,
191
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0))
 
 
192
  return fig
193
 
194
  def make_index_tracks_plotly(df: pd.DataFrame, cols: list[str]):
 
200
  fig.update_layout(height=200, margin=dict(l=10,r=10,t=10,b=10))
201
  return fig
202
  n = len(cols)
203
+ fig = make_subplots(rows=1, cols=n, shared_yaxes=True, horizontal_spacing=0.05) # <-- FIX
204
  idx = np.arange(1, len(df) + 1)
205
  for i, col in enumerate(cols, start=1):
206
  fig.add_trace(
207
  go.Scatter(x=df[col], y=idx, mode="lines", line=dict(color="#333", width=1.2),
208
+ hovertemplate=f"{col}: "+"%{x:.2f}<br>Index: %{y}<extra></extra>",
209
+ name=col, showlegend=False),
210
  row=1, col=i
211
  )
212
  fig.update_xaxes(title_text=col, side="top", row=1, col=i)
 
214
  fig.update_layout(height=650, margin=dict(l=10, r=10, t=40, b=10))
215
  return fig
216
 
217
+ # MPL fallbacks
218
  def cross_plot_mpl(actual, pred, title, size=(3.9,3.9)):
219
  fig, ax = plt.subplots(figsize=size, dpi=100)
220
  ax.scatter(actual, pred, s=14, alpha=0.85, color=COLORS["pred"])
 
244
  ax.legend(loc="best")
245
  return fig
246
 
 
247
  def stats_table(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
248
  cols = [c for c in cols if c in df.columns]
249
  if not cols:
 
252
  out = out.rename(columns={"min": "Min", "max": "Max", "mean": "Mean", "std": "Std"})
253
  return out.reset_index().rename(columns={"index": "Feature"})
254
 
255
+ # ---------- Preview dialogs ----------
256
  @dialog("Preview data")
257
  def preview_modal_dev(book: dict[str, pd.DataFrame], feature_cols: list[str]):
258
  if not book:
 
293
  with t2:
294
  st.dataframe(stats_table(df, feature_cols), use_container_width=True)
295
 
296
+ # ---------------- Model presence ----------------
 
 
297
  MODEL_URL = _get_model_url()
 
298
  def ensure_model_present() -> Path:
299
  for p in [DEFAULT_MODEL, *MODEL_FALLBACKS]:
300
  if p.exists() and p.stat().st_size > 0:
 
336
  infer = infer_features_from_model(model)
337
  if infer: FEATURES = infer
338
 
339
+ # ---------------- Session state ----------------
 
 
340
  if "app_step" not in st.session_state: st.session_state.app_step = "intro"
341
  if "results" not in st.session_state: st.session_state.results = {}
342
  if "train_ranges" not in st.session_state: st.session_state.train_ranges = None
343
 
344
  for k, v in {
345
  "dev_ready": False, "dev_file_loaded": False, "dev_previewed": False,
346
+ "dev_file_signature": None, "dev_file_bytes": b"", "dev_file_name": "",
347
+ "dev_file_rows": 0, "dev_file_cols": 0,
348
  }.items():
349
  if k not in st.session_state: st.session_state[k] = v
350
 
351
+ # ---------------- Hero ----------------
 
 
352
  st.markdown(
353
  f"""
354
  <div class="st-hero">
 
359
  </div>
360
  </div>
361
  """,
362
+ unsafe_allow_html=True
363
  )
364
 
365
+ # ---------------- INTRO ----------------
 
 
366
  if st.session_state.app_step == "intro":
367
  st.header("Welcome!")
368
  st.markdown("This software is developed by *Smart Thinking AI-Solutions Team* to estimate UCS from drilling data.")
 
384
  if st.button("Start Showcase", type="primary", key="start_showcase"):
385
  st.session_state.app_step = "dev"; st.rerun()
386
 
387
+ # ---------------- DEVELOPMENT ----------------
 
 
388
  if st.session_state.app_step == "dev":
389
  st.sidebar.header("Model Development Data")
390
  dev_label = "Upload Data (Excel)" if not st.session_state.dev_file_name else "Replace data (Excel)"
391
  train_test_file = st.sidebar.file_uploader(dev_label, type=["xlsx","xls"], key="dev_upload")
392
 
 
393
  if train_test_file is not None:
394
+ file_bytes = train_test_file.getvalue()
395
+ size = len(file_bytes)
 
 
396
  sig = (train_test_file.name, size)
397
  if sig != st.session_state.dev_file_signature and size > 0:
398
  st.session_state.dev_file_signature = sig
 
413
  f"{st.session_state.dev_file_rows} rows × {st.session_state.dev_file_cols} cols"
414
  )
415
 
 
416
  st.sidebar.markdown('<div class="dev-actions">', unsafe_allow_html=True)
417
  preview_btn = st.sidebar.button("Preview data", use_container_width=True, disabled=not st.session_state.dev_file_loaded)
418
  run_btn = st.sidebar.button("Run Model", type="primary", use_container_width=True)
 
422
  if proceed_clicked and st.session_state.dev_ready:
423
  st.session_state.app_step = "predict"; st.rerun()
424
 
 
425
  helper_top = st.container()
426
  with helper_top:
427
  st.subheader("Model Development")
 
475
  status.update(label="Done ✓", state="complete"); toast("Model run complete 🚀")
476
  st.rerun()
477
 
 
478
  if ("Train" in st.session_state.results) or ("Test" in st.session_state.results):
479
  tab1, tab2 = st.tabs(["Training", "Testing"])
480
  if "Train" in st.session_state.results:
 
533
  except RuntimeError as e:
534
  st.warning(str(e))
535
 
536
+ # ---------------- PREDICTION ----------------
 
 
537
  if st.session_state.app_step == "predict":
538
  st.sidebar.header("Prediction (Validation)")
539
  validation_file = st.sidebar.file_uploader("Upload Validation Excel", type=["xlsx","xls"], key="val_upload")
 
650
  except RuntimeError as e:
651
  st.warning(str(e))
652
 
653
+ # ---------------- Footer ----------------
 
 
654
  st.markdown("---")
655
  st.markdown(
656
  """