UCS2014 commited on
Commit
6dc8e42
·
verified ·
1 Parent(s): 63ff429

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +79 -93
app.py CHANGED
@@ -26,49 +26,33 @@ MODEL_FALLBACKS = [MODELS_DIR / "model.joblib", MODELS_DIR / "model.pkl"]
26
  COLORS = {"pred": "#1f77b4", "actual": "#f2b702", "ref": "#5a5a5a"}
27
 
28
  # =========================
29
- # Page / Theme
30
  # =========================
31
  st.set_page_config(page_title="ST_GeoMech_UCS", page_icon="logo.png", layout="wide")
32
- # … your CSS …
33
- # 🔒 Password gate
34
- add_password_gate()
35
- # from here on, only authenticated users proceed
36
- st.markdown("<style>header, footer{visibility:hidden !important;}</style>", unsafe_allow_html=True)
37
- st.markdown(
38
- """
39
- <style>
40
- .stApp { background: #FFFFFF; }
41
- section[data-testid="stSidebar"] { background: #F6F9FC; }
42
- .block-container { padding-top: .5rem; padding-bottom: .5rem; }
43
- .stButton>button{ background:#007bff; color:#fff; font-weight:bold; border-radius:8px; border:none; padding:10px 24px; }
44
- .stButton>button:hover{ background:#0056b3; }
45
- .st-hero { display:flex; align-items:center; gap:16px; padding-top: 4px; }
46
- .st-hero .brand { width:110px; height:110px; object-fit:contain; }
47
- .st-hero h1 { margin:0; line-height:1.05; }
48
- .st-hero .tagline { margin:2px 0 0 2px; color:#6b7280; font-size:1.05rem; font-style:italic; }
49
- [data-testid="stBlock"]{ margin-top:0 !important; }
50
- </style>
51
- """,
52
- unsafe_allow_html=True
53
- )
54
- # =========================
55
- # Password
56
- # =========================
57
- # --- Password gate (branded, strict) ---
58
  def add_password_gate() -> bool:
59
  """
60
  Shows a branded access screen until the correct password is entered.
61
- Returns True when access is granted.
62
- Blocks (shows admin instructions) if APP_PASSWORD is not configured.
63
  """
64
- # 1) Read password from Secrets or Env
65
- required = ""
66
  try:
67
  required = st.secrets.get("APP_PASSWORD", "")
68
  except Exception:
69
  required = os.environ.get("APP_PASSWORD", "")
70
 
71
- # 2) If not configured, BLOCK with admin message (no accidental bypass)
72
  if not required:
73
  st.markdown(
74
  f"""
@@ -81,8 +65,8 @@ def add_password_gate() -> bool:
81
  </div>
82
  <div style="font-size:1.25rem;font-weight:700;margin:8px 0 4px 0;">Protected Area</div>
83
  <div style="color:#6b7280;margin-bottom:14px;">
84
- Admin action required: set <code>APP_PASSWORD</code> in <b>Settings → Secrets</b> (or as an
85
- environment variable) and restart the Space.
86
  </div>
87
  """,
88
  unsafe_allow_html=True,
@@ -93,7 +77,7 @@ def add_password_gate() -> bool:
93
  if st.session_state.get("auth_ok", False):
94
  return True
95
 
96
- # 4) Branded prompt
97
  st.markdown(
98
  f"""
99
  <div style="display:flex;align-items:center;gap:14px;margin:8px 0 6px 0;">
@@ -110,7 +94,6 @@ def add_password_gate() -> bool:
110
  """,
111
  unsafe_allow_html=True
112
  )
113
-
114
  pwd = st.text_input("Access key", type="password", placeholder="••••••••")
115
  col1, _ = st.columns([1, 3])
116
  with col1:
@@ -120,12 +103,33 @@ def add_password_gate() -> bool:
120
  st.rerun()
121
  else:
122
  st.error("Incorrect key. Please try again.")
123
-
124
- # Don’t proceed until correct
125
  st.stop()
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  # =========================
128
- # Helpers
129
  # =========================
130
  try:
131
  dialog = st.dialog
@@ -173,7 +177,7 @@ def find_sheet(book, names):
173
  if nm.lower() in low2orig: return low2orig[nm.lower()]
174
  return None
175
 
176
- # ---------- Interactive plotting (full outline, bold axis titles) ----------
177
  def cross_plot_interactive(actual, pred, size=(3.9, 3.9)):
178
  """Interactive cross-plot: blue points, dashed 1:1, equal axes, no title, numeric ticks, full box outline."""
179
  a = pd.Series(actual).astype(float)
@@ -184,28 +188,22 @@ def cross_plot_interactive(actual, pred, size=(3.9, 3.9)):
184
  x0, x1 = lo - pad, hi + pad
185
 
186
  fig = go.Figure()
187
-
188
- # Points (blue)
189
  fig.add_trace(go.Scatter(
190
  x=a, y=p, mode="markers",
191
  marker=dict(size=6, color=COLORS["pred"]),
192
  hovertemplate="Actual: %{x:.2f}<br>Pred: %{y:.2f}<extra></extra>",
193
  showlegend=False
194
  ))
195
-
196
- # 1:1 line (dashed grey)
197
  fig.add_trace(go.Scatter(
198
  x=[x0, x1], y=[x0, x1], mode="lines",
199
  line=dict(color=COLORS["ref"], width=1.2, dash="dash"),
200
  hoverinfo="skip", showlegend=False
201
  ))
202
-
203
  fig.update_layout(
204
  paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
205
  margin=dict(l=50, r=10, t=10, b=36),
206
  hovermode="closest", font=dict(size=13)
207
  )
208
- # FULL OUTLINE via mirror=True (top/right) + showline
209
  fig.update_xaxes(
210
  title_text="<b>Actual UCS</b>",
211
  range=[x0, x1], ticks="outside",
@@ -221,8 +219,7 @@ def cross_plot_interactive(actual, pred, size=(3.9, 3.9)):
221
  tickformat=",.0f", scaleanchor="x", scaleratio=1,
222
  automargin=True
223
  )
224
- w = int(size[0] * 100); h = int(size[1] * 100)
225
- fig.update_layout(width=w, height=h)
226
  return fig
227
 
228
  def depth_or_index_track_interactive(df, title=None, include_actual=True):
@@ -234,15 +231,12 @@ def depth_or_index_track_interactive(df, title=None, include_actual=True):
234
  y = np.arange(1, len(df) + 1); y_label = "Point Index"
235
 
236
  fig = go.Figure()
237
-
238
- # Predicted (solid blue)
239
  fig.add_trace(go.Scatter(
240
  x=df["UCS_Pred"], y=y, mode="lines",
241
  line=dict(color=COLORS["pred"], width=1.8),
242
  name="UCS_Pred",
243
  hovertemplate="UCS_Pred: %{x:.2f}<br>"+y_label+": %{y}<extra></extra>"
244
  ))
245
- # Actual (dotted yellow)
246
  if include_actual and TARGET in df.columns:
247
  fig.add_trace(go.Scatter(
248
  x=df[TARGET], y=y, mode="lines",
@@ -250,7 +244,6 @@ def depth_or_index_track_interactive(df, title=None, include_actual=True):
250
  name="UCS (actual)",
251
  hovertemplate="UCS (actual): %{x:.2f}<br>"+y_label+": %{y}<extra></extra>"
252
  ))
253
-
254
  fig.update_layout(
255
  paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
256
  margin=dict(l=60, r=10, t=10, b=36),
@@ -263,7 +256,6 @@ def depth_or_index_track_interactive(df, title=None, include_actual=True):
263
  width=int(3.1 * 100),
264
  height=int((7.6 if depth_col is not None else 7.2) * 100),
265
  )
266
- # FULL OUTLINE via mirror=True (adds bottom/right sides)
267
  fig.update_xaxes(
268
  title_text="<b>UCS</b>", side="top",
269
  ticks="outside", showline=True, linewidth=1.2, linecolor="#444", mirror=True,
@@ -340,27 +332,29 @@ def preview_modal_val(book: dict[str, pd.DataFrame], feature_cols: list[str]):
340
  # =========================
341
  # Model presence
342
  # =========================
343
- MODEL_URL = _get_model_url()
344
-
345
- def ensure_model_present() -> Path:
346
- for p in [DEFAULT_MODEL, *MODEL_FALLBACKS]:
347
- if p.exists() and p.stat().st_size > 0:
348
- return p
349
- if not MODEL_URL:
350
- return None
351
  try:
352
  import requests
353
- DEFAULT_MODEL.parent.mkdir(parents=True, exist_ok=True)
354
  with st.status("Downloading model…", expanded=False):
355
- with requests.get(MODEL_URL, stream=True, timeout=30) as r:
356
  r.raise_for_status()
357
- with open(DEFAULT_MODEL, "wb") as f:
358
- for chunk in r.iter_content(chunk_size=1<<20):
359
  if chunk: f.write(chunk)
360
- return DEFAULT_MODEL
361
  except Exception as e:
362
  st.error(f"Failed to download model from MODEL_URL: {e}")
363
- return None
 
 
 
 
 
 
 
 
 
364
 
365
  model_path = ensure_model_present()
366
  if not model_path:
@@ -379,19 +373,22 @@ if meta_path.exists():
379
  try:
380
  meta = json.loads(meta_path.read_text(encoding="utf-8"))
381
  FEATURES = meta.get("features", FEATURES); TARGET = meta.get("target", TARGET)
382
- except Exception: pass
 
383
  else:
384
  def infer_features_from_model(m):
385
  try:
386
  if hasattr(m, "feature_names_in_") and len(getattr(m, "feature_names_in_")):
387
  return [str(x) for x in m.feature_names_in_]
388
- except Exception: pass
 
389
  try:
390
  if hasattr(m, "steps") and len(m.steps):
391
  last = m.steps[-1][1]
392
  if hasattr(last, "feature_names_in_") and len(last.feature_names_in_):
393
  return [str(x) for x in last.feature_names_in_]
394
- except Exception: pass
 
395
  return None
396
  infer = infer_features_from_model(model)
397
  if infer: FEATURES = infer
@@ -418,16 +415,8 @@ for k, v in {
418
  if k not in st.session_state: st.session_state[k] = v
419
 
420
  # =========================
421
- # Hero header
422
  # =========================
423
- def inline_logo(path="logo.png") -> str:
424
- try:
425
- p = Path(path)
426
- if not p.exists(): return ""
427
- return f"data:image/png;base64,{base64.b64encode(p.read_bytes()).decode('ascii')}"
428
- except Exception:
429
- return ""
430
-
431
  st.markdown(
432
  f"""
433
  <div class="st-hero">
@@ -446,9 +435,7 @@ st.markdown(
446
  # =========================
447
  if st.session_state.app_step == "intro":
448
  st.header("Welcome!")
449
- st.markdown(
450
- "This software is developed by *Smart Thinking AI-Solutions Team* to estimate UCS from drilling data."
451
- )
452
  st.subheader("Expected Input Features (in Order)")
453
  st.markdown(
454
  "- Q, gpm — Flow rate (gallons per minute) \n"
@@ -468,18 +455,17 @@ if st.session_state.app_step == "intro":
468
  st.session_state.app_step = "dev"; st.rerun()
469
 
470
  # =========================
471
- # MODEL DEVELOPMENT
472
  # =========================
473
  if st.session_state.app_step == "dev":
474
- st.sidebar.header("Model Development Data")
475
  dev_label = "Upload Data (Excel)" if not st.session_state.dev_file_name else "Replace data (Excel)"
476
  train_test_file = st.sidebar.file_uploader(dev_label, type=["xlsx","xls"], key="dev_upload")
477
 
478
  # Detect new/changed file and PERSIST BYTES
479
  if train_test_file is not None:
480
  try:
481
- file_bytes = train_test_file.getvalue()
482
- size = len(file_bytes)
483
  except Exception:
484
  file_bytes = b""; size = 0
485
  sig = (train_test_file.name, size)
@@ -497,7 +483,7 @@ if st.session_state.app_step == "dev":
497
  st.session_state.dev_previewed = False
498
  st.session_state.dev_ready = False
499
 
500
- # Sidebar caption (from persisted info)
501
  if st.session_state.dev_file_loaded:
502
  st.sidebar.caption(
503
  f"**Data loaded:** {st.session_state.dev_file_name} • "
@@ -522,7 +508,7 @@ if st.session_state.app_step == "dev":
522
  # ----- ALWAYS-ON TOP: Title + helper -----
523
  helper_top = st.container()
524
  with helper_top:
525
- st.subheader("Model Development")
526
  if st.session_state.dev_ready:
527
  st.success("Case has been built and results are displayed below.")
528
  elif st.session_state.dev_file_loaded and st.session_state.dev_previewed:
@@ -704,7 +690,7 @@ if st.session_state.app_step == "predict":
704
  if sv["oor_pct"] > 0:
705
  st.warning("Some validation inputs fall outside the **training min–max** ranges. Interpret predictions with caution.")
706
 
707
- # Metrics like Development (R² / RMSE / MAE)
708
  metrics_val = st.session_state.results.get("metrics_val")
709
  if metrics_val is not None:
710
  c1, c2, c3 = st.columns(3)
@@ -712,11 +698,11 @@ if st.session_state.app_step == "predict":
712
  c2.metric("RMSE", f"{metrics_val['RMSE']:.4f}")
713
  c3.metric("MAE", f"{metrics_val['MAE']:.4f}")
714
  else:
715
- # Fallback only if actual UCS isn't provided
716
- c1, c2, c3 = st.columns(3)
717
- c1.metric("# points", f"{sv['n_points']}")
718
  c2.metric("Pred min", f"{sv['pred_min']:.2f}")
719
  c3.metric("Pred max", f"{sv['pred_max']:.2f}")
 
720
 
721
  left, right = st.columns([0.9, 0.55])
722
  with left:
@@ -780,4 +766,4 @@ st.markdown(
780
  </div>
781
  """,
782
  unsafe_allow_html=True
783
- )
 
26
  COLORS = {"pred": "#1f77b4", "actual": "#f2b702", "ref": "#5a5a5a"}
27
 
28
  # =========================
29
+ # Page / Theme (must be first)
30
  # =========================
31
  st.set_page_config(page_title="ST_GeoMech_UCS", page_icon="logo.png", layout="wide")
32
+
33
+ # ---------- utilities used by the gate ----------
34
+ def inline_logo(path="logo.png") -> str:
35
+ try:
36
+ p = Path(path)
37
+ if p.exists():
38
+ return f"data:image/png;base64,{base64.b64encode(p.read_bytes()).decode('ascii')}"
39
+ except Exception:
40
+ pass
41
+ return ""
42
+
43
+ # ---------- Password gate (define BEFORE calling) ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  def add_password_gate() -> bool:
45
  """
46
  Shows a branded access screen until the correct password is entered.
47
+ Set the password through Settings → Secrets as APP_PASSWORD (or an env var).
 
48
  """
49
+ # 1) Read required password
 
50
  try:
51
  required = st.secrets.get("APP_PASSWORD", "")
52
  except Exception:
53
  required = os.environ.get("APP_PASSWORD", "")
54
 
55
+ # 2) If not configured, block with an admin hint
56
  if not required:
57
  st.markdown(
58
  f"""
 
65
  </div>
66
  <div style="font-size:1.25rem;font-weight:700;margin:8px 0 4px 0;">Protected Area</div>
67
  <div style="color:#6b7280;margin-bottom:14px;">
68
+ Admin action required: set <code>APP_PASSWORD</code> in <b>Settings → Secrets</b>
69
+ (or as an environment variable) and restart the Space.
70
  </div>
71
  """,
72
  unsafe_allow_html=True,
 
77
  if st.session_state.get("auth_ok", False):
78
  return True
79
 
80
+ # 4) Prompt
81
  st.markdown(
82
  f"""
83
  <div style="display:flex;align-items:center;gap:14px;margin:8px 0 6px 0;">
 
94
  """,
95
  unsafe_allow_html=True
96
  )
 
97
  pwd = st.text_input("Access key", type="password", placeholder="••••••••")
98
  col1, _ = st.columns([1, 3])
99
  with col1:
 
103
  st.rerun()
104
  else:
105
  st.error("Incorrect key. Please try again.")
 
 
106
  st.stop()
107
 
108
+ # 🔒 Enforce gate BEFORE any UI is rendered
109
+ add_password_gate()
110
+
111
+ # After the gate: global CSS/UI may render
112
+ st.markdown("<style>header, footer{visibility:hidden !important;}</style>", unsafe_allow_html=True)
113
+ st.markdown(
114
+ """
115
+ <style>
116
+ .stApp { background: #FFFFFF; }
117
+ section[data-testid="stSidebar"] { background: #F6F9FC; }
118
+ .block-container { padding-top: .5rem; padding-bottom: .5rem; }
119
+ .stButton>button{ background:#007bff; color:#fff; font-weight:bold; border-radius:8px; border:none; padding:10px 24px; }
120
+ .stButton>button:hover{ background:#0056b3; }
121
+ .st-hero { display:flex; align-items:center; gap:16px; padding-top: 4px; }
122
+ .st-hero .brand { width:110px; height:110px; object-fit:contain; }
123
+ .st-hero h1 { margin:0; line-height:1.05; }
124
+ .st-hero .tagline { margin:2px 0 0 2px; color:#6b7280; font-size:1.05rem; font-style:italic; }
125
+ [data-testid="stBlock"]{ margin-top:0 !important; }
126
+ </style>
127
+ """,
128
+ unsafe_allow_html=True
129
+ )
130
+
131
  # =========================
132
+ # Helpers / caching
133
  # =========================
134
  try:
135
  dialog = st.dialog
 
177
  if nm.lower() in low2orig: return low2orig[nm.lower()]
178
  return None
179
 
180
+ # ---------- Interactive plotting ----------
181
  def cross_plot_interactive(actual, pred, size=(3.9, 3.9)):
182
  """Interactive cross-plot: blue points, dashed 1:1, equal axes, no title, numeric ticks, full box outline."""
183
  a = pd.Series(actual).astype(float)
 
188
  x0, x1 = lo - pad, hi + pad
189
 
190
  fig = go.Figure()
 
 
191
  fig.add_trace(go.Scatter(
192
  x=a, y=p, mode="markers",
193
  marker=dict(size=6, color=COLORS["pred"]),
194
  hovertemplate="Actual: %{x:.2f}<br>Pred: %{y:.2f}<extra></extra>",
195
  showlegend=False
196
  ))
 
 
197
  fig.add_trace(go.Scatter(
198
  x=[x0, x1], y=[x0, x1], mode="lines",
199
  line=dict(color=COLORS["ref"], width=1.2, dash="dash"),
200
  hoverinfo="skip", showlegend=False
201
  ))
 
202
  fig.update_layout(
203
  paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
204
  margin=dict(l=50, r=10, t=10, b=36),
205
  hovermode="closest", font=dict(size=13)
206
  )
 
207
  fig.update_xaxes(
208
  title_text="<b>Actual UCS</b>",
209
  range=[x0, x1], ticks="outside",
 
219
  tickformat=",.0f", scaleanchor="x", scaleratio=1,
220
  automargin=True
221
  )
222
+ fig.update_layout(width=int(size[0]*100), height=int(size[1]*100))
 
223
  return fig
224
 
225
  def depth_or_index_track_interactive(df, title=None, include_actual=True):
 
231
  y = np.arange(1, len(df) + 1); y_label = "Point Index"
232
 
233
  fig = go.Figure()
 
 
234
  fig.add_trace(go.Scatter(
235
  x=df["UCS_Pred"], y=y, mode="lines",
236
  line=dict(color=COLORS["pred"], width=1.8),
237
  name="UCS_Pred",
238
  hovertemplate="UCS_Pred: %{x:.2f}<br>"+y_label+": %{y}<extra></extra>"
239
  ))
 
240
  if include_actual and TARGET in df.columns:
241
  fig.add_trace(go.Scatter(
242
  x=df[TARGET], y=y, mode="lines",
 
244
  name="UCS (actual)",
245
  hovertemplate="UCS (actual): %{x:.2f}<br>"+y_label+": %{y}<extra></extra>"
246
  ))
 
247
  fig.update_layout(
248
  paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
249
  margin=dict(l=60, r=10, t=10, b=36),
 
256
  width=int(3.1 * 100),
257
  height=int((7.6 if depth_col is not None else 7.2) * 100),
258
  )
 
259
  fig.update_xaxes(
260
  title_text="<b>UCS</b>", side="top",
261
  ticks="outside", showline=True, linewidth=1.2, linecolor="#444", mirror=True,
 
332
  # =========================
333
  # Model presence
334
  # =========================
335
+ def _download_model(url: str, dest: Path) -> bool:
 
 
 
 
 
 
 
336
  try:
337
  import requests
338
+ dest.parent.mkdir(parents=True, exist_ok=True)
339
  with st.status("Downloading model…", expanded=False):
340
+ with requests.get(url, stream=True, timeout=30) as r:
341
  r.raise_for_status()
342
+ with open(dest, "wb") as f:
343
+ for chunk in r.iter_content(chunk_size=1 << 20):
344
  if chunk: f.write(chunk)
345
+ return True
346
  except Exception as e:
347
  st.error(f"Failed to download model from MODEL_URL: {e}")
348
+ return False
349
+
350
+ MODEL_URL = _get_model_url()
351
+ def ensure_model_present() -> Path | None:
352
+ for p in [DEFAULT_MODEL, *MODEL_FALLBACKS]:
353
+ if p.exists() and p.stat().st_size > 0:
354
+ return p
355
+ if MODEL_URL and _download_model(MODEL_URL, DEFAULT_MODEL):
356
+ return DEFAULT_MODEL
357
+ return None
358
 
359
  model_path = ensure_model_present()
360
  if not model_path:
 
373
  try:
374
  meta = json.loads(meta_path.read_text(encoding="utf-8"))
375
  FEATURES = meta.get("features", FEATURES); TARGET = meta.get("target", TARGET)
376
+ except Exception:
377
+ pass
378
  else:
379
  def infer_features_from_model(m):
380
  try:
381
  if hasattr(m, "feature_names_in_") and len(getattr(m, "feature_names_in_")):
382
  return [str(x) for x in m.feature_names_in_]
383
+ except Exception:
384
+ pass
385
  try:
386
  if hasattr(m, "steps") and len(m.steps):
387
  last = m.steps[-1][1]
388
  if hasattr(last, "feature_names_in_") and len(last.feature_names_in_):
389
  return [str(x) for x in last.feature_names_in_]
390
+ except Exception:
391
+ pass
392
  return None
393
  infer = infer_features_from_model(model)
394
  if infer: FEATURES = infer
 
415
  if k not in st.session_state: st.session_state[k] = v
416
 
417
  # =========================
418
+ # Hero header (after gate)
419
  # =========================
 
 
 
 
 
 
 
 
420
  st.markdown(
421
  f"""
422
  <div class="st-hero">
 
435
  # =========================
436
  if st.session_state.app_step == "intro":
437
  st.header("Welcome!")
438
+ st.markdown("This software is developed by *Smart Thinking AI-Solutions Team* to estimate UCS from drilling data.")
 
 
439
  st.subheader("Expected Input Features (in Order)")
440
  st.markdown(
441
  "- Q, gpm — Flow rate (gallons per minute) \n"
 
455
  st.session_state.app_step = "dev"; st.rerun()
456
 
457
  # =========================
458
+ # Case Building
459
  # =========================
460
  if st.session_state.app_step == "dev":
461
+ st.sidebar.header("Data for Case Building")
462
  dev_label = "Upload Data (Excel)" if not st.session_state.dev_file_name else "Replace data (Excel)"
463
  train_test_file = st.sidebar.file_uploader(dev_label, type=["xlsx","xls"], key="dev_upload")
464
 
465
  # Detect new/changed file and PERSIST BYTES
466
  if train_test_file is not None:
467
  try:
468
+ file_bytes = train_test_file.getvalue(); size = len(file_bytes)
 
469
  except Exception:
470
  file_bytes = b""; size = 0
471
  sig = (train_test_file.name, size)
 
483
  st.session_state.dev_previewed = False
484
  st.session_state.dev_ready = False
485
 
486
+ # Sidebar caption
487
  if st.session_state.dev_file_loaded:
488
  st.sidebar.caption(
489
  f"**Data loaded:** {st.session_state.dev_file_name} • "
 
508
  # ----- ALWAYS-ON TOP: Title + helper -----
509
  helper_top = st.container()
510
  with helper_top:
511
+ st.subheader("Case Building")
512
  if st.session_state.dev_ready:
513
  st.success("Case has been built and results are displayed below.")
514
  elif st.session_state.dev_file_loaded and st.session_state.dev_previewed:
 
690
  if sv["oor_pct"] > 0:
691
  st.warning("Some validation inputs fall outside the **training min–max** ranges. Interpret predictions with caution.")
692
 
693
+ # Prefer dev-like metrics when actual UCS exists
694
  metrics_val = st.session_state.results.get("metrics_val")
695
  if metrics_val is not None:
696
  c1, c2, c3 = st.columns(3)
 
698
  c2.metric("RMSE", f"{metrics_val['RMSE']:.4f}")
699
  c3.metric("MAE", f"{metrics_val['MAE']:.4f}")
700
  else:
701
+ c1, c2, c3, c4 = st.columns(4)
702
+ c1.metric("points", f"{sv['n_points']}")
 
703
  c2.metric("Pred min", f"{sv['pred_min']:.2f}")
704
  c3.metric("Pred max", f"{sv['pred_max']:.2f}")
705
+ c4.metric("OOR %", f"{sv['oor_pct']:.1f}%")
706
 
707
  left, right = st.columns([0.9, 0.55])
708
  with left:
 
766
  </div>
767
  """,
768
  unsafe_allow_html=True
769
+ )