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

Update app.py

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