UCS2014 commited on
Commit
ba6ffbb
·
verified ·
1 Parent(s): adf4324

Update models/bo_meta.json

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