Antonio0616 commited on
Commit
91006dc
ยท
verified ยท
1 Parent(s): df92346

Update pages/1_Simulation.py

Browse files
Files changed (1) hide show
  1. pages/1_Simulation.py +844 -179
pages/1_Simulation.py CHANGED
@@ -1,74 +1,429 @@
1
- # pages/1_Simulation.py
2
- import streamlit as st
3
- import pandas as pd
4
- import numpy as np
 
 
 
 
 
 
 
5
  from datetime import datetime
6
- from predict_blend import BlendPredictor
 
 
 
 
7
  import streamlit as st
8
- import base64
9
 
10
- st.set_page_config(page_title="์‹œ๋ฎฌ๋ ˆ์ด์…˜", layout="wide")
11
 
12
  # ์ธ์ฆ ์ฒดํฌ
13
  if "authenticated" not in st.session_state or not st.session_state["authenticated"]:
14
  st.error("โ›” ์ ‘๊ทผ ๋ถˆ๊ฐ€: ๋จผ์ € ๋ฉ”์ธ ํ™”๋ฉด์—์„œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.")
15
  st.stop()
16
 
17
- st.title("์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์‹คํ–‰")
18
-
19
- # =========================================================
20
- # ๋ชจ๋ธ ๋กœ๋”
21
- # =========================================================
22
- @st.cache_resource(show_spinner=True)
23
- def get_predictor():
24
- return BlendPredictor()
25
-
26
- predictor = get_predictor()
27
-
28
- # =========================================================
29
- # ๊ธฐ๋ณธ๊ฐ’ & ๋งคํ•‘
30
- # =========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  DEFAULT_RESULT = {"THINNING": 0.65, "MAX_FAILURE": 1.02}
32
-
33
  DISPLAY_LABELS = ["440", "590", "780"]
34
  DISPLAY_TO_MODEL = {"440": "440.0", "590": "590.0", "780": "780.0"}
35
-
36
  MATERIAL_THICKNESS_CAP = {"440": 0.17, "590": 0.16, "780": 0.10}
37
 
38
  st.session_state.setdefault("history", [])
39
  st.session_state.setdefault("input_mode", "์ง์ ‘ ์ž…๋ ฅ")
40
- st.session_state.setdefault("material", "590")
41
-
42
- # =========================================================
43
- # ์œ ํ‹ธ ํ•จ์ˆ˜
44
- # =========================================================
45
- def bead_to_flags(bead: str):
46
- if bead == "Left Bead": return 1, 0
47
- if bead == "Right Bead": return 0, 1
48
- if bead == "Double Bead": return 1, 1
49
- return 0, 0
50
-
51
- def calc_thinning(thickness, upperR, lowerR):
52
- """THINNING์€ ๋‹จ์ˆœ ์‚ฐ์‹"""
53
- t = float(thickness); ur = float(upperR); lr = float(lowerR)
54
- base = 0.18 + (0.9 - t) * 0.25
55
- geom = max(0.0, ur - lr) * 0.01
56
- thinning = max(0.05, min(0.8, base + geom))
57
- return round(thinning, 3)
58
-
59
- def build_df(material_display, thickness, diameter, degree, upperR, lowerR, beadType):
60
- lb, rb = bead_to_flags(beadType)
61
- mat_model = DISPLAY_TO_MODEL[material_display]
62
- return pd.DataFrame([{
63
- "material": mat_model,
64
- "thickness": float(thickness),
65
- "diameter": int(diameter),
66
- "degree": int(degree),
67
- "upper_radius": float(upperR),
68
- "lower_radius": float(lowerR),
69
- "LB": int(lb),
70
- "RB": int(rb),
71
- }])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  def val_or_range(single_key, range_key, unit=""):
74
  mode = st.session_state.get("input_mode", "์ง์ ‘ ์ž…๋ ฅ")
@@ -79,207 +434,517 @@ def val_or_range(single_key, range_key, unit=""):
79
  return f"{st.session_state[single_key]}{unit}"
80
  return "-"
81
 
82
- def frange_inclusive(lo: float, hi: float, step: float):
83
- lo = float(lo); hi = float(hi); step = float(step)
84
- n = int(round((hi - lo) / step)) + 1
85
- return [round(lo + i * step, 10) for i in range(n)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
- # =========================================================
88
- # ํƒญ ๊ตฌ์„ฑ
89
- # =========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  tabs = st.tabs(["์กฐ๊ฑด ์„ค์ • & ์‹คํ–‰", "๊ฒฐ๊ณผ ์‹œ๊ฐํ™”", "๊ธฐ๋ก ์กฐํšŒ"])
91
 
92
- # =========================================================
93
  # 1) ์กฐ๊ฑด ์„ค์ • & ์‹คํ–‰
94
- # =========================================================
95
  with tabs[0]:
96
  st.header("์กฐ๊ฑด ์„ค์ •")
97
  st.markdown("---")
98
 
99
- col_b1, col_b2 = st.columns(2)
100
- with col_b1:
101
- st.session_state["beadType"] = st.selectbox(
102
- "๋น„๋“œ ํƒ€์ž… ์„ ํƒ", ["No Bead", "Double Bead", "Left Bead", "Right Bead"]
103
- )
104
- with col_b2:
105
- cur = st.session_state.get("material", "590")
106
- idx = DISPLAY_LABELS.index(cur) if cur in DISPLAY_LABELS else 1
107
- st.session_state["material"] = st.selectbox("์žฌ์งˆ", DISPLAY_LABELS, index=idx)
108
 
109
- st.session_state["input_mode"] = st.radio(
110
- "์ž…๋ ฅ ๋ฐฉ์‹", ["์ง์ ‘ ์ž…๋ ฅ", "๋ฒ”์œ„ ๊ฐ’ ์ž…๋ ฅ"], horizontal=True
111
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- # ---------------- ์ง์ ‘ ์ž…๋ ฅ ----------------
114
  if st.session_state["input_mode"] == "์ง์ ‘ ์ž…๋ ฅ":
 
 
 
115
  col_t, col_d = st.columns(2)
116
  with col_t:
117
- st.session_state["thickness"] = st.selectbox("์†Œ์žฌ ๋‘๊ป˜ (mm)",
118
- [0.7,0.8,0.9,1.0,1.1,1.2], index=2)
119
  with col_d:
120
  st.session_state["diameter"] = st.number_input("์ง๊ฒฝ (mm)", 10, 1000, 20)
121
 
122
  col1, col2 = st.columns(2)
123
  with col1:
124
- st.session_state["upperR"] = st.number_input("์ƒ๋‹จ R", 1.0, 10.0, 4.0)
125
  with col2:
126
- st.session_state["lowerR"] = st.number_input("ํ•˜๋‹จ R", 1.0, 10.0, 3.0)
127
 
128
  st.session_state["degree"] = st.number_input("๊ฐ๋„ (ยฐ)", 0, 90, 75)
129
 
130
- if st.button("์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์‹คํ–‰ํ•˜๊ธฐ", type="primary", use_container_width=True):
131
- df = build_df(
132
- st.session_state["material"],
133
- st.session_state["thickness"],
134
- st.session_state["diameter"],
135
- st.session_state["degree"],
136
- st.session_state["upperR"],
137
- st.session_state["lowerR"],
 
 
 
 
 
138
  st.session_state["beadType"],
139
  )
140
  try:
141
- mf = float(predictor.predict_blend(df)[0]) # โœ… ๋ชจ๋ธ ์˜ˆ์ธก
 
 
142
  except Exception as e:
143
- st.error(f"๋ชจ๋ธ ์˜ˆ์ธก ์‹คํŒจ: {e}")
144
- st.stop()
145
- th = calc_thinning(df.loc[0,"thickness"], df.loc[0,"upper_radius"], df.loc[0,"lower_radius"])
146
  st.session_state.sim_result = {"THINNING": th, "MAX_FAILURE": mf}
147
- st.success("โœ… ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์™„๋ฃŒ!")
 
148
 
149
- # ---------------- ๋ฒ”์œ„ ๊ฐ’ ์ž…๋ ฅ ----------------
150
  else:
151
- st.session_state["thicknessRange"] = st.slider("์†Œ์žฌ ๋‘๊ป˜ ๋ฒ”์œ„ (mm)", 0.7, 1.2, (0.7, 1.0), step=0.1)
152
- st.session_state["diameterRange"] = st.slider("์ง๊ฒฝ ๋ฒ”์œ„ (mm)", 10, 50, (15, 30))
153
- st.session_state["upperRRange"] = st.slider("์ƒ๋‹จ R ๋ฒ”์œ„", 1, 10, (3, 7))
154
- st.session_state["lowerRRange"] = st.slider("ํ•˜๋‹จ R ๋ฒ”์œ„", 1, 10, (2, 4))
155
- st.session_state["degreeRange"] = st.slider("๊ฐ๋„ ๋ฒ”์œ„ (ยฐ)", 60, 90, (72, 87))
156
-
157
- if st.button("์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์‹คํ–‰ํ•˜๊ธฐ", type="primary", use_container_width=True):
158
- th_vals = frange_inclusive(*st.session_state["thicknessRange"], 0.1)
159
- dr_vals = range(st.session_state["diameterRange"][0], st.session_state["diameterRange"][1] + 1)
160
- ur_vals = range(st.session_state["upperRRange"][0], st.session_state["upperRRange"][1] + 1)
161
- lr_vals = range(st.session_state["lowerRRange"][0], st.session_state["lowerRRange"][1] + 1)
162
- dg_vals = range(st.session_state["degreeRange"][0], st.session_state["degreeRange"][1] + 1)
163
-
164
- rows = []
165
- mat_disp = st.session_state["material"]
166
- bead = st.session_state["beadType"]
167
- lb, rb = bead_to_flags(bead)
168
- mat_model = DISPLAY_TO_MODEL[mat_disp]
169
-
170
- for t in th_vals:
171
- for d in dr_vals:
172
- for ur in ur_vals:
173
- for lr in lr_vals:
174
- for dg in dg_vals:
175
- rows.append({
176
- "material": mat_model,
177
- "thickness": float(t),
178
- "diameter": int(d),
179
- "degree": int(dg),
180
- "upper_radius": float(ur),
181
- "lower_radius": float(lr),
182
- "LB": int(lb),
183
- "RB": int(rb),
184
- })
185
-
186
- df_all = pd.DataFrame(rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  try:
188
- mf_pred = predictor.predict_blend(df_all) # โœ… ๋ชจ๋ธ ์˜ˆ์ธก
 
189
  except Exception as e:
190
- st.error(f"๋ชจ๋ธ ์˜ˆ์ธก ์‹คํŒจ: {e}")
191
- st.stop()
192
 
193
- df_all["THINNING"] = [
194
- calc_thinning(r["thickness"], r["upper_radius"], r["lower_radius"])
195
- for _, r in df_all.iterrows()
196
- ]
197
  df_all["MAX_FAILURE"] = mf_pred
198
 
199
- st.session_state.sim_result = {
200
- "THINNING": float(df_all["THINNING"].mean()),
201
- "MAX_FAILURE": float(df_all["MAX_FAILURE"].mean()),
202
- }
203
- st.success(f"โœ… {len(df_all)}๊ฐœ ์กฐํ•ฉ ์‹คํ–‰ ์™„๋ฃŒ!")
204
-
205
- # =========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  # 2) ๊ฒฐ๊ณผ ์‹œ๊ฐํ™”
207
- # =========================================================
208
  with tabs[1]:
209
  st.header("๊ฒฐ๊ณผ ์‹œ๊ฐํ™”")
 
 
 
 
 
 
210
  result_data = st.session_state.get("sim_result", DEFAULT_RESULT)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
- c1, c2 = st.columns(2)
213
- with c1:
214
- st.metric("THINNING", f"{result_data['THINNING']:.3f}")
215
- with c2:
216
- st.metric("MAX_FAILURE", f"{result_data['MAX_FAILURE']:.3f}")
217
 
218
- # =========================================================
219
- # 3) ๊ธฐ๋ก ์กฐํšŒ
220
- # =========================================================
 
 
 
 
 
 
221
  with tabs[2]:
222
  st.header("๊ธฐ๋ก ์กฐํšŒ")
223
 
224
- col1, col2, col3, col4 = st.columns([1, 1, 1, 1])
225
  with col1:
226
  save_btn = st.button("ํ˜„์žฌ ๊ฒฐ๊ณผ ์ €์žฅ", type="primary", use_container_width=True)
227
  with col2:
228
  select_all_btn = st.button("์ „์ฒด ์„ ํƒ", use_container_width=True)
229
  with col3:
230
  delete_btn = st.button("์„ ํƒ ํ•ญ๋ชฉ ์‚ญ์ œ", use_container_width=True)
231
- with col4:
232
- export_btn = st.button("CSV ๋‚ด๋ณด๋‚ด๊ธฐ", use_container_width=True)
233
 
 
234
  if save_btn:
235
  if "sim_result" not in st.session_state:
236
  st.warning("๋จผ์ € ์‹œ๋ฎฌ๋ ˆ์ด์…˜์„ ์‹คํ–‰ํ•˜์„ธ์š”.")
237
  else:
238
- new_entry = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  "์„ ํƒ": False,
240
- "Index": len(st.session_state.history) + 1,
241
  "์ €์žฅ์‹œ๊ฐ": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
242
- "๋น„๋“œ ํƒ€์ž…": st.session_state.get("beadType", "-"),
243
- "์ž…๋ ฅ ๋ฐฉ์‹": st.session_state.get("input_mode", "-"),
244
  "์†Œ์žฌ ๋‘๊ป˜ (mm)": val_or_range("thickness", "thicknessRange", " mm"),
245
- "์žฌ์งˆ": st.session_state.get("material", "-"),
246
  "์ง๊ฒฝ": val_or_range("diameter", "diameterRange", " mm"),
247
  "๊ฐ๋„": val_or_range("degree", "degreeRange", "ยฐ"),
248
  "์ƒ๋‹จ R": val_or_range("upperR", "upperRRange"),
249
  "ํ•˜๋‹จ R": val_or_range("lowerR", "lowerRRange"),
250
- "THINNING": st.session_state.sim_result.get("THINNING"),
251
- "MAX_FAILURE": st.session_state.sim_result.get("MAX_FAILURE"),
 
 
 
252
  }
253
- st.session_state.history.append(new_entry)
254
- st.success("ํ˜„์žฌ ๊ฒฐ๊ณผ๊ฐ€ ๊ธฐ๋ก์— ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
255
 
256
- if export_btn:
257
- if st.session_state.history:
258
- df_export = pd.DataFrame(st.session_state.history)
259
- csv = df_export.to_csv(index=False).encode("utf-8-sig")
260
- st.download_button("CSV ๋‹ค์šด๋กœ๋“œ", csv, file_name="simulation_history.csv", mime="text/csv")
261
- else:
262
- st.info("๋‚ด๋ณด๋‚ผ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.")
263
 
 
264
  if st.session_state.history:
265
  df = pd.DataFrame(st.session_state.history)
266
  cols = ["์„ ํƒ"] + [c for c in df.columns if c != "์„ ํƒ"]
267
  df = df[cols]
268
 
269
  if select_all_btn:
270
- for row in st.session_state.history:
271
- row["์„ ํƒ"] = True
272
- df = pd.DataFrame(st.session_state.history)[cols]
273
 
274
  st.subheader(f"๊ธฐ๋ก ํ…Œ์ด๋ธ” (์ด {len(df)}๊ฑด, ์ฒดํฌ ํ›„ ์‚ญ์ œ ๊ฐ€๋Šฅ)")
275
- edited_df = st.data_editor(df, hide_index=True, use_container_width=True)
 
 
276
 
277
  if delete_btn:
278
  selected_index = edited_df[edited_df["์„ ํƒ"] == True]["Index"].tolist()
279
  st.session_state.history = [
280
- rec for rec in st.session_state.history if rec["Index"] not in selected_index
281
  ]
282
  st.success(f"{len(selected_index)}๊ฐœ ํ•ญ๋ชฉ ์‚ญ์ œ ์™„๋ฃŒ!")
283
  st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  else:
285
- st.info("์•„์ง ์ €์žฅ๋œ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.")
 
1
+ st.title("์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์‹คํ–‰")
2
+ # app_simulation_multi.py
3
+ # - ์ง์ ‘ ์ž…๋ ฅ: ๋‹จ์ผ ์žฌ์งˆ/๋น„๋“œ
4
+ # - ๋ฒ”์œ„ ์ž…๋ ฅ: ๋‹ค์ค‘ ์žฌ์งˆ/๋‹ค์ค‘ ๋น„๋“œ โ†’ ์ „์ฒด ๊ฒฝ์šฐ ์ˆ˜ ์ƒ์„ฑ ํ›„ ๊ทœ์น™/์Šค์œ• ํ•„ํ„ฐ โ†’ MAX_FAILURE & THINNING ๋™์‹œ ์˜ˆ์ธก(Blend)
5
+ from dashboard_theme.theme import inject
6
+ inject("graphite_gold")
7
+
8
+ import os
9
+ import itertools
10
+ import warnings
11
+ from pathlib import Path
12
  from datetime import datetime
13
+ from decimal import Decimal
14
+ from typing import Dict, List, Tuple, Union
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
  import streamlit as st
 
19
 
20
+ warnings.filterwarnings("ignore", category=FutureWarning)
21
 
22
  # ์ธ์ฆ ์ฒดํฌ
23
  if "authenticated" not in st.session_state or not st.session_state["authenticated"]:
24
  st.error("โ›” ์ ‘๊ทผ ๋ถˆ๊ฐ€: ๋จผ์ € ๋ฉ”์ธ ํ™”๋ฉด์—์„œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.")
25
  st.stop()
26
 
27
+ # =========================================
28
+ # ํŽ˜์ด์ง€ ์„ค์ • & ์Šคํƒ€์ผ
29
+ # =========================================
30
+
31
+ def _set_env_from_secrets(key: str):
32
+ try:
33
+ val = st.secrets[key]
34
+ except Exception:
35
+ val = None
36
+ if val:
37
+ os.environ[key] = str(val)
38
+
39
+ _set_env_from_secrets("FS_THIN_ART_DIR")
40
+ _set_env_from_secrets("FS_MF_ART_DIR")
41
+
42
+ # ---- Compact ๋ชจ๋“œ: ์ „์ฒด ์—ฌ๋ฐฑ/ํŒจ๋”ฉ ์ถ•์†Œ ----
43
+ st.markdown("""
44
+ <style>
45
+ /* ์ „์ฒด ์ปจํ…Œ์ด๋„ˆ ์ƒํ•˜ ํŒจ๋”ฉ ์ค„์ด๊ธฐ */
46
+ .block-container{padding-top:0.6rem !important; padding-bottom:1.25rem !important;}
47
+ /* ์ œ๋ชฉ/์†Œ์ œ๋ชฉ ๊ฐ„๊ฒฉ */
48
+ h1, h2, h3{margin-top:0.4rem !important; margin-bottom:0.6rem !important;}
49
+ /* ํŒจ๋„๊ณผ metric ์นด๋“œ ๊ฐ„๊ฒฉ/๋†’์ด ์กฐ๊ธˆ ์ถ•์†Œ */
50
+ .panel{margin:8px 0 12px !important; padding:16px 16px 12px !important;}
51
+ .metric{min-height:128px !important; padding:18px !important;}
52
+ .metric .value{margin:4px 0 6px !important;}
53
+ /* ๊ตฌ๋ถ„์„  ๊ฐ„๊ฒฉ */
54
+ hr, .stDivider{margin:10px 0 !important;}
55
+ /* ์„น์…˜ ์‚ฌ์ด ๋งˆ์ง„ ์กฐ๊ธˆ์”ฉ ์ค„์ด๊ธฐ */
56
+ .stMarkdown, [data-testid="stMarkdownContainer"]{margin:0 !important;}
57
+ /* ์บก์…˜-์ต์ŠคํŒฌ๋” ํƒ€์ดํŠธ ๋ฌถ๊ธฐ */
58
+ .tight-block .stCaption, .tight-block small{margin-top:0 !important; display:block;}
59
+ .tight-block [data-testid="stExpander"] > details{margin-top:6px !important;}
60
+ </style>
61
+ """, unsafe_allow_html=True)
62
+
63
+ # =========================================
64
+ # ๊ธฐ๋ณธ๊ฐ’ & ์ „์—ญ ์ƒํƒœ
65
+ # =========================================
66
  DEFAULT_RESULT = {"THINNING": 0.65, "MAX_FAILURE": 1.02}
 
67
  DISPLAY_LABELS = ["440", "590", "780"]
68
  DISPLAY_TO_MODEL = {"440": "440.0", "590": "590.0", "780": "780.0"}
 
69
  MATERIAL_THICKNESS_CAP = {"440": 0.17, "590": 0.16, "780": 0.10}
70
 
71
  st.session_state.setdefault("history", [])
72
  st.session_state.setdefault("input_mode", "์ง์ ‘ ์ž…๋ ฅ")
73
+ st.session_state.setdefault("material", "590") # ์ง์ ‘ ์ž…๋ ฅ ๊ธฐ๋ณธ๊ฐ’
74
+
75
+ # =========================================
76
+ # ํŒŒ์ผ ํƒ์ƒ‰
77
+ # =========================================
78
+ def _find_file(name: str):
79
+ here = Path(__file__).parent
80
+ for p in [here / name, here / "assets" / name, Path.cwd() / name, Path("/mnt/data") / name]:
81
+ if p.exists():
82
+ return str(p)
83
+ return ""
84
+
85
+ # ํ•„์š” ์‹œ ์ ˆ๋Œ€๊ฒฝ๋กœ๋กœ ๋ฐ”๊ฟ”๋„ ๋จ
86
+ RULES_XLSX = _find_file("ํ˜•์ƒ๋ณ„_ํ—ˆ์šฉ๋ฒ”์œ„์ •๋ฆฌํ‘œ.xlsx")
87
+ SWEEP_XLSX = _find_file("์ง๊ฒฝ๋ณ„_์„ค๊ณ„๋ณ€๊ฒฝํ—ˆ์šฉ๋ฒ”์œ„.xlsx")
88
+
89
+ # =========================================
90
+ # ์กฐํ•ฉ ์ƒ์„ฑ & ํ•„ํ„ฐ ์œ ํ‹ธ
91
+ # =========================================
92
+ def dseq(start: float, stop: float, step: float, q="0.001") -> List[float]:
93
+ s, e, stp = map(lambda x: Decimal(str(x)), [start, stop, step])
94
+ vals, cur = [], s
95
+ while cur <= e + Decimal("1e-12"):
96
+ vals.append(float(cur.quantize(Decimal(q))))
97
+ cur += stp
98
+ return vals
99
+
100
+ def bead_to_lr(bead_value: Union[str, None]) -> Tuple[int, int]:
101
+ mapping = {None:(2,2),"none":(0,0),"right":(0,1),"left":(1,0),"double":(1,1)}
102
+ key = bead_value.lower() if isinstance(bead_value, str) else bead_value
103
+ return mapping.get(key, (0, 0))
104
+
105
+ def make_all_combinations(cfg: Dict) -> pd.DataFrame:
106
+ bead_values = cfg.get("beads") or [None]
107
+ bead_info = [(b, *bead_to_lr(b)) for b in bead_values]
108
+
109
+ materials = cfg["materials"]
110
+ thickness = dseq(cfg["min_thickness"], cfg["max_thickness"], cfg["thickness_step"])
111
+ diameter = [int(x) for x in dseq(cfg["min_diameter"], cfg["max_diameter"], cfg["diameter_step"], q="1")]
112
+ upper_r = dseq(cfg["upper_min"], cfg["upper_max"], cfg["upper_step"])
113
+ lower_r = dseq(cfg["lower_min"], cfg["lower_max"], cfg["lower_step"])
114
+ degree = [int(x) for x in dseq(cfg["min_degree"], cfg["max_degree"], cfg["degree_step"], q="1")]
115
+
116
+ grid = itertools.product(materials, thickness, upper_r, lower_r, diameter, degree, bead_info)
117
+ rows = []
118
+ for mat, th, ur, lr, dia, deg, bead_t in grid:
119
+ bead_name, lb, rb = bead_t
120
+ rows.append((mat, th, ur, lr, dia, deg, bead_name, lb, rb))
121
+
122
+ df = pd.DataFrame(
123
+ rows,
124
+ columns=["material", "thickness", "upper_radius", "lower_radius",
125
+ "diameter", "degree", "bead", "LB", "RB"]
126
+ )
127
+ return df
128
+
129
+ # ----- Sweep ํ•œ๊ณ„ -----
130
+ def build_limit_dicts(df_sweep: pd.DataFrame):
131
+ t = df_sweep.copy().replace("F", np.nan)
132
+ if "Sweep" in t.columns:
133
+ t = t.set_index("Sweep")
134
+ new_cols = []
135
+ for c in t.columns:
136
+ try: new_cols.append(int(c))
137
+ except: new_cols.append(c)
138
+ t.columns = new_cols
139
+ long = t.stack(dropna=False).reset_index()
140
+ long.columns = ["row", "degree", "limit"]
141
+ tmp = long["row"].str.extract(r"(?P<diameter>\d+)_(?P<which>upper|lower)_radius")
142
+ long = pd.concat([long, tmp], axis=1)
143
+ long["diameter"] = pd.to_numeric(long["diameter"], errors="coerce")
144
+ long["degree"] = pd.to_numeric(long["degree"], errors="coerce")
145
+ long["limit"] = pd.to_numeric(long["limit"], errors="coerce")
146
+ upper = long[long["which"]=="upper"].dropna(subset=["diameter","degree"])
147
+ lower = long[long["which"]=="lower"].dropna(subset=["diameter","degree"])
148
+ upper_dict = {(int(d), int(g)): v for d, g, v in zip(upper["diameter"], upper["degree"], upper["limit"]) }
149
+ lower_dict = {(int(d), int(g)): v for d, g, v in zip(lower["diameter"], lower["degree"], lower["limit"]) }
150
+ return upper_dict, lower_dict
151
+
152
+ def filter_grid_by_sweep_limits(df_grid: pd.DataFrame, df_sweep: pd.DataFrame) -> pd.DataFrame:
153
+ upper_dict, lower_dict = build_limit_dicts(df_sweep)
154
+ key = list(zip(df_grid["diameter"].astype(int), df_grid["degree"].astype(int)))
155
+ df_grid = df_grid.copy()
156
+ df_grid["limit_upper"] = [upper_dict.get(k, np.nan) for k in key]
157
+ df_grid["limit_lower"] = [lower_dict.get(k, np.nan) for k in key]
158
+ not_nan = df_grid["limit_upper"].notna() & df_grid["limit_lower"].notna()
159
+ within = (df_grid["upper_radius"] <= df_grid["limit_upper"]) & \
160
+ (df_grid["lower_radius"] <= df_grid["limit_lower"])
161
+ return df_grid[not_nan & within].reset_index(drop=True)
162
+
163
+ # ----- ๊ทœ์น™ ์‹œํŠธ -----
164
+ SHEET_BY_BEAD = {"left":"left", "right":"right", "double":"both", "none":"none"}
165
+
166
+ def _normalize_input_df(df: pd.DataFrame) -> pd.DataFrame:
167
+ df2 = df.copy()
168
+ df2.columns = [str(c).strip() for c in df2.columns]
169
+ rename = {}
170
+ for c in df2.columns:
171
+ lc = c.lower().strip()
172
+ if lc == "diamater": rename[c] = "diameter"
173
+ elif lc in ("material","diameter","degree","bead"): rename[c] = lc
174
+ df2 = df2.rename(columns=rename)
175
+ need = {"material","diameter","degree","bead"}
176
+ missing = need - set(df2.columns)
177
+ if missing:
178
+ raise ValueError(f"์ž…๋ ฅ ๋ฐ์ดํ„ฐํ”„๋ ˆ์ž„์— ํ•„์š”ํ•œ ์ปฌ๋Ÿผ์ด ์—†์Šต๋‹ˆ๋‹ค: {missing}")
179
+ df2["bead"] = df2["bead"].astype(str).str.strip().str.lower()
180
+ for c in ["material","diameter","degree"]:
181
+ df2[c] = pd.to_numeric(df2[c], errors="coerce")
182
+ return df2.dropna(subset=["material","diameter","degree"]).copy()
183
+
184
+ def _read_rule_sheet(xlsx_path: str, sheet_name: str) -> pd.DataFrame:
185
+ rule = pd.read_excel(xlsx_path, sheet_name=sheet_name)
186
+ rule.columns = rule.columns.str.strip().str.lower()
187
+ rule = rule.rename(columns={"diamater":"diameter"})
188
+ need = {"material","diameter","min_degree","max_degree"}
189
+ missing = need - set(rule.columns)
190
+ if missing:
191
+ raise ValueError(f"๊ทœ์น™ ์‹œํŠธ '{sheet_name}'์— ํ•„์š”ํ•œ ์ปฌ๋Ÿผ์ด ์—†์Šต๋‹ˆ๋‹ค: {missing}")
192
+ for c in need: rule[c] = pd.to_numeric(rule[c], errors="coerce")
193
+ rule = rule.dropna(subset=list(need)).copy()
194
+ rule = rule.astype({"material":"int64","diameter":"int64"})
195
+ return rule[["material","diameter","min_degree","max_degree"]]
196
+
197
+ def _apply_rules(df_part: pd.DataFrame, rule: pd.DataFrame) -> pd.DataFrame:
198
+ if df_part.empty: return df_part.copy()
199
+ df_part = df_part[df_part["material"].isin(rule["material"].unique())].copy()
200
+ if df_part.empty: return df_part
201
+ merged = df_part.merge(rule, on=["material","diameter"], how="left")
202
+ mask = (
203
+ merged["min_degree"].notna()
204
+ & merged["max_degree"].notna()
205
+ & (merged["degree"] >= merged["min_degree"])
206
+ & (merged["degree"] <= merged["max_degree"])
207
+ )
208
+ return merged.loc[mask, df_part.columns].reset_index(drop=True)
209
+
210
+ def filter_all_by_bead(df: pd.DataFrame, rules_xlsx: str) -> pd.DataFrame:
211
+ base = _normalize_input_df(df)
212
+ outs = []
213
+ for bead_value, sheet in SHEET_BY_BEAD.items():
214
+ part = base[base["bead"] == bead_value].copy()
215
+ if part.empty: continue
216
+ rule = _read_rule_sheet(rules_xlsx, sheet)
217
+ outs.append(_apply_rules(part, rule))
218
+ if not outs: return base.iloc[0:0].copy()
219
+ return pd.concat(outs, axis=0, ignore_index=True).reset_index(drop=True)
220
+
221
+ # =========================================
222
+ # ์˜ˆ์ธก๊ธฐ (๋‘ ํƒ€๊นƒ ๋™์‹œ)
223
+ # =========================================
224
+ import torch
225
+ import torch.nn as nn
226
+ import lightgbm as lgb
227
+
228
+ ART_DIR_MF_DEFAULT = "artifacts_blend"
229
+ ART_DIR_THIN_DEFAULT = "artifacts_blend_thinning"
230
+ CAT_COL_DEFAULT = "material"
231
+ NUM_COLS_DEFAULT = ["thickness","diameter","degree","upper_radius","lower_radius","LB","RB"]
232
+
233
+ class FTTransformer(nn.Module):
234
+ def __init__(self, n_materials:int, n_num:int, d_model:int=192, nhead:int=8,
235
+ num_layers:int=4, dim_ff:int=768, dropout:float=0.15):
236
+ super().__init__()
237
+ self.mat_emb = nn.Embedding(n_materials, d_model)
238
+ self.num_linears = nn.ModuleList([nn.Linear(1, d_model) for _ in range(n_num)])
239
+ self.cls = nn.Parameter(torch.zeros(1, 1, d_model))
240
+ nn.init.trunc_normal_(self.cls, std=0.02)
241
+ enc_layer = nn.TransformerEncoderLayer(
242
+ d_model=d_model, nhead=nhead, dim_feedforward=dim_ff,
243
+ dropout=dropout, batch_first=True, activation='gelu', norm_first=True
244
+ )
245
+ self.encoder = nn.TransformerEncoder(enc_layer, num_layers=num_layers)
246
+ self.head = nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, d_model), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model, 1))
247
+ def forward(self, mat_ids, x_num):
248
+ B = x_num.size(0)
249
+ mat_tok = self.mat_emb(mat_ids).unsqueeze(1)
250
+ num_tok = torch.cat([lin(x_num[:, i:i+1]).unsqueeze(1) for i, lin in enumerate(self.num_linears)], dim=1)
251
+ tokens = torch.cat([self.cls.expand(B, -1, -1), mat_tok, num_tok], dim=1)
252
+ h = self.encoder(tokens)
253
+ return self.head(h[:, 0, :])
254
+
255
+ def _scale_like_fold(X_num: np.ndarray, mean: np.ndarray, scale: np.ndarray) -> np.ndarray:
256
+ return ((X_num - mean) / scale).astype(np.float32)
257
+
258
+ def _canonize_list(materials): return [str(m).strip() for m in materials]
259
+ def _build_alias2canon(canon_list):
260
+ alias2canon = {}
261
+ for c in canon_list:
262
+ alias2canon[c] = c
263
+ s = c.strip(); alias2canon[s] = c
264
+ if "." in s: alias2canon[s.rstrip("0").rstrip(".")] = c
265
+ try:
266
+ v = float(s); alias2canon[str(v)] = c
267
+ if v.is_integer(): alias2canon[str(int(v))] = c
268
+ except: pass
269
+ return alias2canon
270
+ def _first_existing(*paths):
271
+ for p in paths:
272
+ if os.path.exists(p): return p
273
+ return None
274
+ def _load_json_like(art_dir: str, basename: str) -> dict:
275
+ p1 = os.path.join(art_dir, f"{basename}.json"); p2 = os.path.join(art_dir, basename)
276
+ p = _first_existing(p1, p2)
277
+ if p is None: raise FileNotFoundError(f"Missing {basename}(.json) in {self.art_dir}")
278
+ import json; return json.load(open(p, "r", encoding="utf-8"))
279
+ def _load_columns_meta(art_dir: str):
280
+ p = _first_existing(os.path.join(art_dir, "columns_thinning.json"), os.path.join(art_dir, "columns.json"))
281
+ if not p: return None
282
+ import json; return json.load(open(p, "r", encoding="utf-8"))
283
+
284
+ class _SingleTargetBlendPredictor:
285
+ def __init__(self, art_dir:str, lgbm_prefix:str, ftt_prefix:str, alpha_json:str,
286
+ cat_col_default:str=CAT_COL_DEFAULT, num_cols_default:List[str]=None,
287
+ allow_columns_meta:bool=False, unknown_policy:str="error"):
288
+ self.art_dir = art_dir; self.lgbm_prefix=lgbm_prefix; self.ftt_prefix=ftt_prefix
289
+ self.alpha_json=alpha_json; self.unknown_policy=unknown_policy
290
+ self.cat_col = cat_col_default; self.num_cols = list(num_cols_default or NUM_COLS_DEFAULT)
291
+ if allow_columns_meta:
292
+ meta = _load_columns_meta(art_dir)
293
+ if meta: self.cat_col = meta.get("cat_col", self.cat_col); self.num_cols = meta.get("num_cols", self.num_cols)
294
+ self.folds_ft = self._load_ft_folds()
295
+ self.boosters = self._load_lgbm_folds()
296
+ self.materials = self._load_materials()
297
+ self.best_alpha = float(_load_json_like(art_dir, self.alpha_json)["best_alpha"])
298
+ self.materials_canon = _canonize_list(self.materials)
299
+ self.alias2canon = _build_alias2canon(self.materials_canon)
300
+ self.mat2id = {m:i for i,m in enumerate(self.materials_canon)}
301
+
302
+ def _load_ft_folds(self):
303
+ folds=[]
304
+ for fold in range(1,11):
305
+ p = os.path.join(self.art_dir, f"{self.ftt_prefix}{fold}.pt")
306
+ if not os.path.exists(p):
307
+ if folds: break
308
+ continue
309
+ ckpt = torch.load(p, map_location="cpu", weights_only=False)
310
+ model = FTTransformer(len(ckpt["materials"]), len(ckpt["num_cols"]))
311
+ model.load_state_dict(ckpt["state_dict"]); model.eval()
312
+ folds.append({"model":model,"materials":ckpt["materials"],"num_cols":ckpt["num_cols"],
313
+ "scaler_mean":np.array(ckpt["scaler_mean"],dtype=np.float32),
314
+ "scaler_scale":np.array(ckpt["scaler_scale"],dtype=np.float32)})
315
+ if not folds: raise FileNotFoundError(f"No FT checkpoints found in {self.art_dir} (prefix={self.ftt_prefix})")
316
+ return folds
317
+ def _load_lgbm_folds(self):
318
+ boosters=[]
319
+ for fold in range(1,11):
320
+ p = _first_existing(os.path.join(self.art_dir,f"{self.lgbm_prefix}{fold}.txt"),
321
+ os.path.join(self.art_dir,f"{self.lgbm_prefix}{fold}"))
322
+ if p is None:
323
+ if boosters: break
324
+ continue
325
+ boosters.append(lgb.Booster(model_file=p))
326
+ if not boosters: raise FileNotFoundError(f"No LightGBM model files found in {self.art_dir} (prefix={self.lgbm_prefix})")
327
+ return boosters
328
+ def _load_materials(self):
329
+ try: return _load_json_like(self.art_dir,"materials")["materials"]
330
+ except FileNotFoundError: return self.folds_ft[0]["materials"]
331
+ def _prep_df(self, df_new: pd.DataFrame) -> pd.DataFrame:
332
+ df = df_new.copy()
333
+ need = [self.cat_col] + self.num_cols
334
+ missing = [c for c in need if c not in df.columns]
335
+ if missing: raise ValueError(f"Missing columns in input: {missing}")
336
+ df[self.cat_col] = df[self.cat_col].astype(str).str.strip()
337
+ df["_mat_canon"] = df[self.cat_col].map(self.alias2canon)
338
+ if self.unknown_policy == "error":
339
+ unknown = df.loc[df["_mat_canon"].isna(), self.cat_col].unique().tolist()
340
+ if unknown: raise ValueError(f"Unknown materials in input {unknown}. Known materials: {self.materials_canon[:10]}{' ...' if len(self.materials_canon)>10 else ''}")
341
+ df["_mat_id"] = df["_mat_canon"].map(self.mat2id).astype(int)
342
+ else:
343
+ df["_mat_canon"] = df["_mat_canon"].fillna(self.materials_canon[0])
344
+ df["_mat_id"] = df["_mat_canon"].map(self.mat2id).astype(int)
345
+ df[self.num_cols] = df[self.num_cols].apply(pd.to_numeric, errors="coerce")
346
+ if df[self.num_cols].isnull().any().any():
347
+ bad = df[self.num_cols].columns[df[self.num_cols].isnull().any()].tolist()
348
+ raise ValueError(f"Non-numeric values detected in columns: {bad}")
349
+ return df
350
+ def predict_ft(self, df_new: pd.DataFrame) -> np.ndarray:
351
+ df = self._prep_df(df_new); mids = torch.tensor(df["_mat_id"].values, dtype=torch.long)
352
+ preds=[]
353
+ for f in self.folds_ft:
354
+ Xn = df[f["num_cols"]].values.astype(np.float32)
355
+ x_scaled = _scale_like_fold(Xn, f["scaler_mean"], f["scaler_scale"])
356
+ with torch.no_grad():
357
+ p = f["model"](mids, torch.tensor(x_scaled, dtype=torch.float32)).cpu().numpy().ravel()
358
+ preds.append(p)
359
+ return np.mean(preds, axis=0)
360
+ def predict_lgbm(self, df_new: pd.DataFrame) -> np.ndarray:
361
+ df = self._prep_df(df_new)
362
+ X = df[[self.cat_col] + self.num_cols].copy()
363
+ X[self.cat_col] = pd.Categorical(df["_mat_canon"], categories=self.materials_canon)
364
+ preds = [bst.predict(X, num_iteration=getattr(bst,"best_iteration",None)) for bst in self.boosters]
365
+ return np.mean(preds, axis=0)
366
+ def predict_blend(self, df_new: pd.DataFrame, alpha: float|None=None) -> np.ndarray:
367
+ alpha = self.best_alpha if alpha is None else alpha
368
+ return alpha * self.predict_ft(df_new) + (1 - alpha) * self.predict_lgbm(df_new)
369
+
370
+ class MultiTargetBlendPredictor:
371
+ def __init__(self, art_dir_mf:str, art_dir_thin:str, unknown_policy:str="error"):
372
+ self.mf = _SingleTargetBlendPredictor(art_dir=art_dir_mf, lgbm_prefix="lgbm_fold", ftt_prefix="ftt_fold",
373
+ alpha_json="blend_alpha", allow_columns_meta=False,
374
+ unknown_policy=unknown_policy)
375
+ self.thin = _SingleTargetBlendPredictor(art_dir=art_dir_thin, lgbm_prefix="lgbm_thinning_fold",
376
+ ftt_prefix="ftt_thinning_fold", alpha_json="blend_alpha_thinning",
377
+ allow_columns_meta=True, unknown_policy=unknown_policy)
378
+ def predict_both(self, df_new: pd.DataFrame, alpha_mf: float|None=None, alpha_th: float|None=None):
379
+ return {
380
+ "blend_max_failure": self.mf.predict_blend(df_new, alpha_mf),
381
+ "blend_thinning": self.thin.predict_blend(df_new, alpha_th),
382
+ "lgbm_max_failure": self.mf.predict_blend(df_new, 0.0),
383
+ "dl_max_failure": self.mf.predict_blend(df_new, 1.0),
384
+ "lgbm_thinning": self.thin.predict_blend(df_new, 0.0),
385
+ "dl_thinning": self.thin.predict_blend(df_new, 1.0),
386
+ }
387
+
388
+ @st.cache_resource(show_spinner=False)
389
+ def get_predictor():
390
+ art_mf = os.environ.get("FS_MF_ART_DIR", ART_DIR_MF_DEFAULT)
391
+ art_th = os.environ.get("FS_THIN_ART_DIR", ART_DIR_THIN_DEFAULT)
392
+ return MultiTargetBlendPredictor(art_dir_mf=art_mf, art_dir_thin=art_th, unknown_policy="fallback0")
393
+
394
+ def predict_both_blend(df: pd.DataFrame):
395
+ out = get_predictor().predict_both(df)
396
+ return out["blend_max_failure"], out["blend_thinning"]
397
+
398
+ # =========================================
399
+ # UI ์œ ํ‹ธ
400
+ # =========================================
401
+ def bead_to_flags_ui(bead: str):
402
+ if bead == "Left Bead": return 1,0
403
+ if bead == "Right Bead": return 0,1
404
+ if bead == "Double Bead": return 1,1
405
+ return 0,0
406
+
407
+ def _bead_key_from_label(label: str) -> str:
408
+ return {"No Bead":"none","Left Bead":"left","Right Bead":"right","Double Bead":"double"}[label]
409
+
410
+ # (์—…๊ทธ๋ ˆ์ด๋“œ) ์•„์ด์ฝ˜ ์ง€์› Metric ์นด๋“œ
411
+ def metric_card(label: str, value: float, lo: float, hi: float, icon: str = "๐Ÿ“‰"):
412
+ ok = lo <= float(value) <= hi
413
+ cls = "ok" if ok else "bad"
414
+ status_text = "์ •์ƒ" if ok else "๋ฒ”์œ„ ๋ฐ–"
415
+ st.markdown(
416
+ f"""
417
+ <div class="metric {cls}" style="text-align:center;">
418
+ <div class="label" style="font-weight:600; margin-bottom:6px;">{icon} {label}</div>
419
+ <div class="value" style="font-size:2rem; font-weight:bold;">{float(value):.3f}</div>
420
+ <div class="chip" style="margin-top:4px; margin-bottom:4px;">{status_text}</div>
421
+ <div class="range" style="font-size:0.85rem; color:gray;">ํ—ˆ์šฉ๋ฒ”์œ„: {lo:.2f} ~ {hi:.2f}</div>
422
+ </div>
423
+ """,
424
+ unsafe_allow_html=True
425
+ )
426
+
427
 
428
  def val_or_range(single_key, range_key, unit=""):
429
  mode = st.session_state.get("input_mode", "์ง์ ‘ ์ž…๋ ฅ")
 
434
  return f"{st.session_state[single_key]}{unit}"
435
  return "-"
436
 
437
+ def render_cap_table():
438
+ with st.expander("์ƒํ•œ ๊ธฐ์ค€ํ‘œ ๋ณด๊ธฐ", expanded=False):
439
+ st.markdown("""
440
+ <table style="width:100%; border-collapse: collapse;" border="1">
441
+ <tr><th>์žฌ์งˆ(ํ‘œ๊ธฐ)</th><th>๋ชจ๋ธ ๋ผ๋ฒจ</th><th>๋‘๊ป˜ ๊ฐ์†Œ์œจ(ํ—ˆ์šฉ ์ƒํ•œ)</th></tr>
442
+ <tr><td>440</td><td>440.0</td><td style="color:red">0.17</td></tr>
443
+ <tr><td>590</td><td>590.0</td><td style="color:red">0.16</td></tr>
444
+ <tr><td>780</td><td>780.0</td><td style="color:red">0.10</td></tr>
445
+ </table>
446
+ """, unsafe_allow_html=True)
447
+
448
+ def build_df_single(material_display, thickness, diameter, degree, upperR, lowerR, beadType):
449
+ lb, rb = bead_to_flags_ui(beadType)
450
+ mat_model = DISPLAY_TO_MODEL[material_display]
451
+ return pd.DataFrame([{
452
+ "material": mat_model,
453
+ "thickness": float(thickness),
454
+ "diameter": int(diameter),
455
+ "degree": int(degree),
456
+ "upper_radius": float(upperR),
457
+ "lower_radius": float(lowerR),
458
+ "LB": int(lb), "RB": int(rb),
459
+ }])
460
 
461
+ @st.cache_resource(show_spinner=False)
462
+ def get_sweep_df():
463
+ return pd.read_excel(SWEEP_XLSX, sheet_name=0) if SWEEP_XLSX else None
464
+
465
+ @st.cache_resource(show_spinner=False)
466
+ def get_sweep_dicts():
467
+ df = get_sweep_df()
468
+ if df is None: return {}, {}
469
+ return build_limit_dicts(df)
470
+
471
+ def validate_direct_input(material, diameter, degree, bead_label, upperR, lowerR):
472
+ if RULES_XLSX:
473
+ bead_key = _bead_key_from_label(bead_label)
474
+ rule = _read_rule_sheet(RULES_XLSX, SHEET_BY_BEAD[bead_key])
475
+ row = rule[(rule["material"] == int(material)) & (rule["diameter"] == int(diameter))]
476
+ if not row.empty:
477
+ r = row.iloc[0]; mn, mx = int(r["min_degree"]), int(r["max_degree"])
478
+ if not (mn <= int(degree) <= mx):
479
+ return False, f"๊ฐ๋„ {degree}ยฐ๋Š” ๊ทœ์น™ ๋ฒ”์œ„({mn}~{mx}ยฐ) ๋ฐ–์ž…๋‹ˆ๋‹ค."
480
+ else:
481
+ return True, None
482
+ upper_dict, lower_dict = get_sweep_dicts()
483
+ key = (int(diameter), int(degree))
484
+ u = upper_dict.get(key, np.nan); l = lower_dict.get(key, np.nan)
485
+ if np.isnan(u) or np.isnan(l): return False, "์Šค์œ•ํ‘œ์— ์—†๋Š” ์ง๊ฒฝ/๊ฐ๋„ ์กฐํ•ฉ์ž…๋‹ˆ๋‹ค."
486
+ if float(upperR) > float(u) or float(lowerR) > float(l):
487
+ return False, f"R ํ•œ๊ณ„ ์ดˆ๊ณผ: ์ƒ๋‹จR โ‰ค {u}, ํ•˜๋‹จR โ‰ค {l} ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."
488
+ return True, None
489
+
490
+ def _reset_optimum_summary():
491
+ for k in ["best_filter_thin","best_filter_mf","best_all_thin","best_all_mf"]:
492
+ st.session_state.pop(k, None)
493
+ st.session_state.pop("topcard_source", None)
494
+
495
+ # =========================================
496
+ # ํƒญ UI
497
+ # =========================================
498
  tabs = st.tabs(["์กฐ๊ฑด ์„ค์ • & ์‹คํ–‰", "๊ฒฐ๊ณผ ์‹œ๊ฐํ™”", "๊ธฐ๋ก ์กฐํšŒ"])
499
 
500
+ # -----------------------------------------
501
  # 1) ์กฐ๊ฑด ์„ค์ • & ์‹คํ–‰
502
+ # -----------------------------------------
503
  with tabs[0]:
504
  st.header("์กฐ๊ฑด ์„ค์ •")
505
  st.markdown("---")
506
 
507
+ st.subheader("์ž…๋ ฅ ๋ชจ๋“œ")
508
+ st.session_state["input_mode"] = st.radio("์ž…๋ ฅ ๋ฐฉ์‹", ["์ง์ ‘ ์ž…๋ ฅ", "๋ฒ”์œ„ ๊ฐ’ ์ž…๋ ฅ"], horizontal=True, label_visibility="collapsed", key="mode_radio")
509
+ _prev_mode = st.session_state.get("_prev_input_mode")
510
+ if _prev_mode is not None and _prev_mode != st.session_state["input_mode"]:
511
+ _reset_optimum_summary()
512
+ st.session_state["_prev_input_mode"] = st.session_state["input_mode"]
 
 
 
513
 
514
+ col_b1, col_b2 = st.columns(2)
515
+ if st.session_state["input_mode"] == "์ง์ ‘ ์ž…๋ ฅ":
516
+ with col_b1:
517
+ st.session_state["beadType"] = st.selectbox("๋น„๋“œ ํƒ€์ž… ์„ ํƒ", ["No Bead","Double Bead","Left Bead","Right Bead"])
518
+ with col_b2:
519
+ cur = st.session_state.get("material", "590")
520
+ idx = DISPLAY_LABELS.index(cur) if cur in DISPLAY_LABELS else 1
521
+ st.session_state["material"] = st.selectbox("์žฌ์งˆ", DISPLAY_LABELS, index=idx)
522
+ else:
523
+ with col_b1:
524
+ st.session_state["beadTypes_multi"] = st.multiselect(
525
+ "๋น„๋“œ ํƒ€์ž… ์„ ํƒ (๋ณต์ˆ˜ ๊ฐ€๋Šฅ)", ["No Bead","Double Bead","Left Bead","Right Bead"],
526
+ default=["Right Bead"])
527
+ with col_b2:
528
+ default_materials = [st.session_state.get("material","590")]
529
+ st.session_state["materials_multi"] = st.multiselect(
530
+ "์žฌ์งˆ (๋ณต์ˆ˜ ๊ฐ€๋Šฅ)", DISPLAY_LABELS, default=default_materials)
531
+
532
+ st.subheader("์„ฑํ˜• ์กฐ๊ฑด")
533
+ st.markdown("<div style='height:6px'></div>", unsafe_allow_html=True)
534
 
 
535
  if st.session_state["input_mode"] == "์ง์ ‘ ์ž…๋ ฅ":
536
+ for key in ["diameterRange","degreeRange","upperRRange","lowerRRange","thicknessRange"]:
537
+ st.session_state.pop(key, None)
538
+
539
  col_t, col_d = st.columns(2)
540
  with col_t:
541
+ st.session_state["thickness"] = st.selectbox("์†Œ์žฌ ๋‘๊ป˜ (mm)", [0.7,0.8,0.9,1.0,1.1,1.2], index=2)
 
542
  with col_d:
543
  st.session_state["diameter"] = st.number_input("์ง๊ฒฝ (mm)", 10, 1000, 20)
544
 
545
  col1, col2 = st.columns(2)
546
  with col1:
547
+ st.session_state["upperR"] = st.number_input("์ƒ๋‹จ R", 1, 100, 4)
548
  with col2:
549
+ st.session_state["lowerR"] = st.number_input("ํ•˜๋‹จ R", 1, 100, 3)
550
 
551
  st.session_state["degree"] = st.number_input("๊ฐ๋„ (ยฐ)", 0, 90, 75)
552
 
553
+ if st.button("์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์‹คํ–‰ํ•˜๊ธฐ", use_container_width=True, type="primary"):
554
+ _reset_optimum_summary()
555
+ ok, err = validate_direct_input(
556
+ st.session_state["material"], st.session_state["diameter"], st.session_state["degree"],
557
+ st.session_state["beadType"], st.session_state["upperR"], st.session_state["lowerR"]
558
+ )
559
+ if not ok:
560
+ st.error(f"๊ทœ์น™ ์œ„๋ฐ˜: {err}")
561
+ st.stop()
562
+
563
+ df = build_df_single(
564
+ st.session_state["material"], st.session_state["thickness"], st.session_state["diameter"],
565
+ st.session_state["degree"], st.session_state["upperR"], st.session_state["lowerR"],
566
  st.session_state["beadType"],
567
  )
568
  try:
569
+ with st.spinner("๋ชจ๋ธ ์˜ˆ์ธก ์ค‘์ž…๋‹ˆ๋‹คโ€ฆ"):
570
+ mf_arr, th_arr = predict_both_blend(df)
571
+ mf = float(mf_arr[0]); th = float(th_arr[0])
572
  except Exception as e:
573
+ st.error(f"๋ชจ๋ธ ์˜ˆ์ธก ์‹คํŒจ: {e}"); st.stop()
574
+
 
575
  st.session_state.sim_result = {"THINNING": th, "MAX_FAILURE": mf}
576
+ st.session_state.topcard_source = "single"
577
+ st.success("โœ… ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์™„๋ฃŒ! (Blend ๋ชจ๋ธ ์‚ฌ์šฉ)")
578
 
 
579
  else:
580
+ for key in ["diameter","degree","upperR","lowerR","thickness"]:
581
+ st.session_state.pop(key, None)
582
+
583
+ col_t, col_d = st.columns(2)
584
+ with col_t:
585
+ st.session_state["thicknessRange"] = st.slider("์†Œ์žฌ ๋‘๊ป˜ ๋ฒ”์œ„ (mm)", 0.7, 1.2, (0.7, 1.0), step=0.1)
586
+ with col_d:
587
+ st.session_state["diameterRange"] = st.slider("์ง๊ฒฝ ๋ฒ”์œ„ (mm)", 10, 50, (15, 30))
588
+
589
+ col1, col2 = st.columns(2)
590
+ with col1:
591
+ st.session_state["upperRRange"] = st.slider("์ƒ๋‹จ R ๋ฒ”์œ„", 1, 15, (3, 7))
592
+ with col2:
593
+ st.session_state["lowerRRange"] = st.slider("ํ•˜๋‹จ R ๋ฒ”์œ„", 1, 10, (2, 4))
594
+
595
+ st.session_state["degreeRange"] = st.slider("๊ฐ๋„ ๋ฒ”์œ„ (ยฐ)", 60, 90, (72, 87))
596
+
597
+ st.divider()
598
+ st.subheader("๊ฒฐ๊ณผ ํ•„ํ„ฐ ์กฐ๊ฑด (์„ ํƒ)")
599
+ st.session_state["apply_post_filter"] = st.checkbox("๊ฒฐ๊ณผ ํ•„ํ„ฐ ์ ์šฉ (THINNING โ‰ค, MAX FAILURE โ‰ค)", value=False)
600
+
601
+ selected_mats = st.session_state.get("materials_multi", []) or DISPLAY_LABELS
602
+ caps = [MATERIAL_THICKNESS_CAP[m] for m in selected_mats]
603
+ default_thin_cap = float(min(caps)) if len(caps) else 0.16
604
+
605
+ f2, f3 = st.columns([1,1])
606
+ with f2:
607
+ st.session_state.setdefault("filter_thinning_max", default_thin_cap)
608
+ st.session_state["filter_thinning_max"] = st.number_input(
609
+ "THINNING โ‰ค", 0.0, 1.0, float(st.session_state["filter_thinning_max"]),
610
+ step=0.01, format="%.2f", disabled=not st.session_state["apply_post_filter"]
611
+ )
612
+ with f3:
613
+ st.session_state.setdefault("filter_max_failure_max", 1.0)
614
+ st.session_state["filter_max_failure_max"] = st.number_input(
615
+ "MAX FAILURE โ‰ค", 0.0, 2.0, float(st.session_state["filter_max_failure_max"]),
616
+ step=0.01, format="%.2f", disabled=not st.session_state["apply_post_filter"]
617
+ )
618
+
619
+ if st.button("์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์‹คํ–‰ํ•˜๊ธฐ", use_container_width=True, type="primary"):
620
+ _reset_optimum_summary()
621
+
622
+ bead_keys = [_bead_key_from_label(b) for b in (st.session_state.get("beadTypes_multi") or ["Right Bead"])]
623
+ mats_disp = st.session_state.get("materials_multi") or DISPLAY_LABELS
624
+ mats_int = [int(m) for m in mats_disp]
625
+
626
+ cfg = {
627
+ "materials": mats_int,
628
+ "min_thickness": st.session_state["thicknessRange"][0],
629
+ "max_thickness": st.session_state["thicknessRange"][1],
630
+ "thickness_step": 0.1,
631
+ "min_diameter": st.session_state["diameterRange"][0],
632
+ "max_diameter": st.session_state["diameterRange"][1],
633
+ "diameter_step": 1,
634
+ "upper_min": st.session_state["upperRRange"][0],
635
+ "upper_max": st.session_state["upperRRange"][1],
636
+ "upper_step": 1.0,
637
+ "lower_min": st.session_state["lowerRRange"][0],
638
+ "lower_max": st.session_state["lowerRRange"][1],
639
+ "lower_step": 1.0,
640
+ "min_degree": st.session_state["degreeRange"][0],
641
+ "max_degree": st.session_state["degreeRange"][1],
642
+ "degree_step": 1,
643
+ "beads": bead_keys,
644
+ }
645
+ df_all = make_all_combinations(cfg)
646
+
647
+ if RULES_XLSX:
648
+ df_all = filter_all_by_bead(df_all, RULES_XLSX)
649
+ else:
650
+ st.warning("๊ทœ์น™ ํŒŒ์ผ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. (ํ˜•์ƒ๋ณ„_ํ—ˆ์šฉ๋ฒ”์œ„์ •๋ฆฌํ‘œ.xlsx)")
651
+ sweep_df = get_sweep_df()
652
+ if sweep_df is not None and not df_all.empty:
653
+ df_all = filter_grid_by_sweep_limits(df_all, sweep_df)
654
+ elif sweep_df is None:
655
+ st.warning("์Šค์œ• ํ•œ๊ณ„ ํŒŒ์ผ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. (์ง๊ฒฝ๋ณ„_์„ค๊ณ„๋ณ€๊ฒฝํ—ˆ์šฉ๋ฒ”์œ„.xlsx)")
656
+
657
+ if df_all.empty:
658
+ st.warning("๊ทœ์น™/์Šค์œ• ํ•œ๊ณ„ ์ ์šฉ ํ›„ ๋‚จ๋Š” ์กฐํ•ฉ์ด ์—†์Šต๋‹ˆ๋‹ค.")
659
+ st.stop()
660
+
661
+ pred_df = df_all.copy()
662
+ pred_df["material"] = pred_df["material"].astype(int).astype(str).map(DISPLAY_TO_MODEL)
663
+
664
  try:
665
+ with st.spinner("๋ชจ๋ธ ์˜ˆ์ธก ์ค‘์ž…๋‹ˆ๋‹คโ€ฆ"):
666
+ mf_pred, th_pred = predict_both_blend(pred_df)
667
  except Exception as e:
668
+ st.error(f"๋ชจ๋ธ ์˜ˆ์ธก ์‹คํŒจ: {e}"); st.stop()
 
669
 
670
+ df_all["THINNING"] = th_pred
 
 
 
671
  df_all["MAX_FAILURE"] = mf_pred
672
 
673
+ if st.session_state.get("apply_post_filter", False):
674
+ thin_thr = float(st.session_state["filter_thinning_max"])
675
+ mf_thr = float(st.session_state["filter_max_failure_max"])
676
+ ok_mask = (df_all["THINNING"] <= thin_thr) & (df_all["MAX_FAILURE"] <= mf_thr)
677
+ matched = df_all.loc[ok_mask].copy()
678
+ else:
679
+ matched = df_all.copy()
680
+
681
+ total = len(df_all)
682
+
683
+ def _best_rows(df_ok: pd.DataFrame, df_all: pd.DataFrame):
684
+ best = {"filter_thin": None, "filter_mf": None, "all_thin": None, "all_mf": None}
685
+ if len(df_ok):
686
+ best["filter_thin"] = df_ok.loc[df_ok["THINNING"].idxmin()]
687
+ best["filter_mf"] = df_ok.loc[df_ok["MAX_FAILURE"].idxmin()]
688
+ best["all_thin"] = df_all.loc[df_all["THINNING"].idxmin()]
689
+ best["all_mf"] = df_all.loc[df_all["MAX_FAILURE"].idxmin()]
690
+ return best
691
+
692
+ best = _best_rows(matched if len(matched) else df_all, df_all)
693
+ st.session_state.best_filter_thin = None if matched.empty else best["filter_thin"].to_dict()
694
+ st.session_state.best_filter_mf = None if matched.empty else best["filter_mf"].to_dict()
695
+ st.session_state.best_all_thin = best["all_thin"].to_dict()
696
+ st.session_state.best_all_mf = best["all_mf"].to_dict()
697
+
698
+ if len(matched) and st.session_state.get("apply_post_filter", False):
699
+ st.session_state.sim_result = {
700
+ "THINNING": float(best["filter_thin"]["THINNING"]),
701
+ "MAX_FAILURE": float(best["filter_mf"]["MAX_FAILURE"]),
702
+ }
703
+ st.session_state.topcard_source = "filter"
704
+ else:
705
+ st.session_state.sim_result = {
706
+ "THINNING": float(best["all_thin"]["THINNING"]),
707
+ "MAX_FAILURE": float(best["all_mf"]["MAX_FAILURE"]),
708
+ }
709
+ st.session_state.topcard_source = "overall"
710
+
711
+ if len(matched):
712
+ now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
713
+ matched = matched.copy()
714
+ matched["์„ ํƒ"] = False
715
+ matched["Index"] = None
716
+ matched["์ €์žฅ์‹œ๊ฐ"] = now_str
717
+ matched["์žฌ์งˆ"] = matched["material"].astype(int).astype(str)
718
+
719
+ start_idx = len(st.session_state.history)
720
+ for i in range(len(matched)):
721
+ matched.at[matched.index[i],"Index"] = start_idx + i + 1
722
+
723
+ matched = matched[[
724
+ "์„ ํƒ","Index","์ €์žฅ์‹œ๊ฐ","bead",
725
+ "thickness","์žฌ์งˆ","diameter","degree","upper_radius","lower_radius",
726
+ "THINNING","MAX_FAILURE"
727
+ ]].rename(columns={
728
+ "bead":"๋น„๋“œ ํƒ€์ž…",
729
+ "thickness":"์†Œ์žฌ ๋‘๊ป˜ (mm)",
730
+ "upper_radius":"์ƒ๋‹จ R",
731
+ "lower_radius":"ํ•˜๋‹จ R",
732
+ })
733
+ st.session_state.history.extend(matched.to_dict("records"))
734
+ if st.session_state.get("apply_post_filter", False):
735
+ st.success(f"โœ… ์ด {total}๊ฐœ ์กฐํ•ฉ ์ค‘ **ํ•„ํ„ฐ๋ฅผ ๋งŒ์กฑํ•œ {len(matched)}๊ฐœ**๋ฅผ ๊ธฐ๋ก์— ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.")
736
+ else:
737
+ st.success(f"โœ… ํ•„ํ„ฐ ๋ฏธ์ ์šฉ: **์ด {len(matched)}๊ฐœ ์ „์ฒด ์กฐํ•ฉ**์„ ๊ธฐ๋ก์— ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.")
738
+ else:
739
+ if st.session_state.get("apply_post_filter", False):
740
+ st.warning(f"ํ•„ํ„ฐ ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋Š” ์กฐํ•ฉ์ด ์—†์Šต๋‹ˆ๋‹ค. (์ด {total}๊ฐœ ์กฐํ•ฉ)")
741
+ else:
742
+ st.warning("๋‚จ๋Š” ์กฐํ•ฉ์ด ์—†์Šต๋‹ˆ๋‹ค.")
743
+
744
+ # -----------------------------------------
745
  # 2) ๊ฒฐ๊ณผ ์‹œ๊ฐํ™”
746
+ # -----------------------------------------
747
  with tabs[1]:
748
  st.header("๊ฒฐ๊ณผ ์‹œ๊ฐํ™”")
749
+
750
+ # (์„ ํƒ) ์ด ํƒญ์—์„œ hr/st.divider๋ฅผ ์ˆจ๊ธฐ๊ณ  ์‹ถ์œผ๋ฉด ์ฃผ์„ ํ•ด์ œ
751
+ # st.markdown("""
752
+ # <style>#results-tab hr, #results-tab .stDivider{display:none!important}</style>
753
+ # <div id="results-tab">""", unsafe_allow_html=True)
754
+
755
  result_data = st.session_state.get("sim_result", DEFAULT_RESULT)
756
+ src = st.session_state.get("topcard_source", "")
757
+ if src == "filter": st.caption("์นด๋“œ ๊ฐ’: **ํ•„ํ„ฐ ๋‚ด ์ตœ์ ** (THINNING ์ตœ์†Œ / MAX FAILURE ์ตœ์†Œ)")
758
+ elif src == "overall": st.caption("์นด๋“œ ๊ฐ’: **์ „์ฒด ํƒ์ƒ‰ ์ตœ์ ** (THINNING ์ตœ์†Œ / MAX FAILURE ์ตœ์†Œ)")
759
+ elif src == "single": st.caption("์นด๋“œ ๊ฐ’: **๋‹จ์ผ ์ž…๋ ฅ ๊ฒฐ๊ณผ**")
760
+
761
+ col1, col2 = st.columns(2, gap="small")
762
+ with col1:
763
+ st.session_state.setdefault("material", "590")
764
+ cur_mat = st.session_state["material"]
765
+ thin_cap = MATERIAL_THICKNESS_CAP.get(cur_mat, 0.16)
766
+ st.session_state["thinning_min"] = 0.0
767
+ st.session_state["thinning_max"] = thin_cap
768
+ metric_card("THINNING (๋‘๊ป˜ ๊ฐ์†Œ์œจ)",
769
+ result_data.get("THINNING", 0.0),
770
+ float(st.session_state["thinning_min"]),
771
+ float(st.session_state["thinning_max"]))
772
+ with col2:
773
+ st.session_state.setdefault("max_failure_min", 0.00)
774
+ st.session_state.setdefault("max_failure_max", 0.97)
775
+ metric_card("MAX FAILURE",
776
+ result_data.get("MAX_FAILURE", 0.0),
777
+ float(st.session_state["max_failure_min"]),
778
+ float(st.session_state["max_failure_max"]))
779
+
780
+ cL, cR = st.columns(2, gap="small")
781
+
782
+ def _summary_block(title: str, row_thin: pd.Series, row_mf: pd.Series):
783
+ st.subheader(title)
784
+ st.markdown("**THINNING ์ตœ์†Œ**")
785
+ if row_thin is not None and len(row_thin):
786
+ lb, rb = int(row_thin.get('LB',0)), int(row_thin.get('RB',0))
787
+ bead_label = ("No Bead" if (lb==0 and rb==0) else ("Left Bead" if (lb==1 and rb==0)
788
+ else ("Right Bead" if (lb==0 and rb==1) else "Double Bead")))
789
+ st.markdown(
790
+ f"- ์žฌ์งˆ: **{row_thin.get('material','-')}** (๋น„๋“œ: **{bead_label}**)<br>"
791
+ f"- ๋‘๊ป˜: **{row_thin.get('thickness','-')} mm**, ์ง๊ฒฝ: **{row_thin.get('diameter','-')} mm**, ๊ฐ๋„: **{row_thin.get('degree','-')}ยฐ**<br>"
792
+ f"- ์ƒ๋‹จ R: **{row_thin.get('upper_radius','-')}**, ํ•˜๋‹จ R: **{row_thin.get('lower_radius','-')}**<br>"
793
+ f"- **THINNING: {row_thin.get('THINNING',0):.3f}**, MAX_FAILURE: {row_thin.get('MAX_FAILURE',0):.3f}",
794
+ unsafe_allow_html=True)
795
+ else:
796
+ st.caption("ํ•ด๋‹น ์—†์Œ")
797
+ st.markdown("---")
798
+ st.markdown("**MAX_FAILURE ์ตœ์†Œ**")
799
+ if row_mf is not None and len(row_mf):
800
+ lb, rb = int(row_mf.get('LB',0)), int(row_mf.get('RB',0))
801
+ bead_label = ("No Bead" if (lb==0 and rb==0) else ("Left Bead" if (lb==1 and rb==0)
802
+ else ("Right Bead" if (lb==0 and rb==1) else "Double Bead")))
803
+ st.markdown(
804
+ f"- ์žฌ์งˆ: **{row_mf.get('material','-')}** (๋น„๋“œ: **{bead_label}**)<br>"
805
+ f"- ๋‘๊ป˜: **{row_mf.get('thickness','-')} mm**, ์ง๊ฒฝ: **{row_mf.get('diameter','-')} mm**, ๊ฐ๋„: **{row_mf.get('degree','-')}ยฐ**<br>"
806
+ f"- ์ƒ๋‹จ R: **{row_mf.get('upper_radius','-')}**, ํ•˜๋‹จ R: **{row_mf.get('lower_radius','-')}**<br>"
807
+ f"- THINNING: {row_mf.get('THINNING',0):.3f}, **MAX_FAILURE: {row_mf.get('MAX_FAILURE',0):.3f}**",
808
+ unsafe_allow_html=True)
809
+ else:
810
+ st.caption("ํ•ด๋‹น ์—†์Œ")
811
+
812
+ with cL:
813
+ _summary_block(
814
+ "ํ•„ํ„ฐ ๋‚ด ์ตœ์ ",
815
+ None if st.session_state.get("best_filter_thin") is None else pd.Series(st.session_state["best_filter_thin"]),
816
+ None if st.session_state.get("best_filter_mf") is None else pd.Series(st.session_state["best_filter_mf"]),
817
+ )
818
+ with cR:
819
+ _summary_block(
820
+ "์ „์ฒด ํƒ์ƒ‰ ์ตœ์ ",
821
+ pd.Series(st.session_state.get("best_all_thin", {})) if st.session_state.get("best_all_thin") else None,
822
+ pd.Series(st.session_state.get("best_all_mf", {})) if st.session_state.get("best_all_mf") else None,
823
+ )
824
+
825
+ # โœ… ๊ธฐ์ค€ ์žฌ์งˆ & ์ƒํ•œํ‘œ โ€” ๊ฐ™์€ ์œ„์น˜(ํ•œ ์—ด)์— ์„ธ๋กœ๋กœ ์ •๋ ฌ
826
+ with st.container():
827
+ material_list = DISPLAY_LABELS
828
+ cur = st.session_state.get("material", "590")
829
+ default_idx = material_list.index(cur) if cur in material_list else 1
830
+
831
+ sel = st.selectbox("๊ธฐ์ค€ ์žฌ์งˆ", material_list, index=default_idx, key="material_for_result")
832
+ st.session_state["material"] = sel
833
+
834
+ cap = MATERIAL_THICKNESS_CAP[sel]
835
+ st.session_state["thinning_min"] = 0.0
836
+ st.session_state["thinning_max"] = cap
837
+ st.caption(f"ํ˜„์žฌ ๊ธฐ์ค€ ์žฌ์งˆ: {sel} (๋‘๊ป˜ ๊ฐ์†Œ์œจ ์ƒํ•œ {cap:.2f})")
838
 
839
+ # ๋ฐ”๋กœ ์•„๋ž˜์— ์ƒํ•œ ๊ธฐ์ค€ํ‘œ๋ฅผ ๋ถ™์—ฌ์„œ ํ‘œ์‹œ
840
+ st.markdown('<div class="tight-block">', unsafe_allow_html=True)
841
+ st.caption("์žฌ์งˆ๋ณ„ ๋‘๊ป˜ ๊ฐ์†Œ์œจ ์ƒํ•œ")
842
+ render_cap_table()
843
+ st.markdown('</div>', unsafe_allow_html=True)
844
 
845
+
846
+ # (์„ ํƒ) ์œ„์—์„œ ์—ด์—ˆ์œผ๋ฉด ๋‹ซ๊ธฐ
847
+ # st.markdown("</div>", unsafe_allow_html=True)
848
+
849
+
850
+
851
+ # -----------------------------------------
852
+ # 3) ๊ธฐ๋ก ์กฐํšŒ โœ… ์ „์ฒด ๊ต์ฒด
853
+ # -----------------------------------------
854
  with tabs[2]:
855
  st.header("๊ธฐ๋ก ์กฐํšŒ")
856
 
857
+ col1, col2, col3 = st.columns([1, 1, 1])
858
  with col1:
859
  save_btn = st.button("ํ˜„์žฌ ๊ฒฐ๊ณผ ์ €์žฅ", type="primary", use_container_width=True)
860
  with col2:
861
  select_all_btn = st.button("์ „์ฒด ์„ ํƒ", use_container_width=True)
862
  with col3:
863
  delete_btn = st.button("์„ ํƒ ํ•ญ๋ชฉ ์‚ญ์ œ", use_container_width=True)
 
 
864
 
865
+ # ===== ํ˜„์žฌ ๊ฒฐ๊ณผ ์ €์žฅ ์ฒ˜๋ฆฌ =====
866
  if save_btn:
867
  if "sim_result" not in st.session_state:
868
  st.warning("๋จผ์ € ์‹œ๋ฎฌ๋ ˆ์ด์…˜์„ ์‹คํ–‰ํ•˜์„ธ์š”.")
869
  else:
870
+ # ๋‹ค์Œ ์ธ๋ฑ์Šค
871
+ try:
872
+ next_idx = max([r.get("Index", 0) for r in st.session_state.history]) + 1 if st.session_state.history else 1
873
+ except Exception:
874
+ next_idx = len(st.session_state.history) + 1
875
+
876
+ # ์ž…๋ ฅ ๋ชจ๋“œ/๋ผ๋ฒจ
877
+ input_mode = st.session_state.get("input_mode", "์ง์ ‘ ์ž…๋ ฅ")
878
+ if input_mode == "์ง์ ‘ ์ž…๋ ฅ":
879
+ bead_label = st.session_state.get("beadType", "-")
880
+ material_label = st.session_state.get("material", "-")
881
+ else:
882
+ bead_label = ", ".join(st.session_state.get("beadTypes_multi", [])) or "-"
883
+ material_label = ", ".join(st.session_state.get("materials_multi", [])) or "-"
884
+
885
+ # ๋‹จ์ผ ๊ฐ’๋„ ์žˆ์œผ๋ฉด ํ•จ๊ป˜ ์ €์žฅ(ํ‘œ์—์„œ ํ•„ํ„ฐ/์ •๋ ฌ ํŽธํ•˜๊ฒŒ)
886
+ diameter_val = st.session_state.get("diameter")
887
+ degree_val = st.session_state.get("degree")
888
+
889
+ new_row = {
890
  "์„ ํƒ": False,
891
+ "Index": next_idx,
892
  "์ €์žฅ์‹œ๊ฐ": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
893
+ "๋น„๋“œ ํƒ€์ž…": bead_label,
894
+ "์ž…๋ ฅ ๋ฐฉ์‹": input_mode,
895
  "์†Œ์žฌ ๋‘๊ป˜ (mm)": val_or_range("thickness", "thicknessRange", " mm"),
896
+ "์žฌ์งˆ": material_label,
897
  "์ง๊ฒฝ": val_or_range("diameter", "diameterRange", " mm"),
898
  "๊ฐ๋„": val_or_range("degree", "degreeRange", "ยฐ"),
899
  "์ƒ๋‹จ R": val_or_range("upperR", "upperRRange"),
900
  "ํ•˜๋‹จ R": val_or_range("lowerR", "lowerRRange"),
901
+ "THINNING": float(st.session_state.sim_result.get("THINNING")),
902
+ "MAX_FAILURE": float(st.session_state.sim_result.get("MAX_FAILURE")),
903
+ # ์ฐธ๊ณ ์šฉ ์›์‹œ ์ˆซ์ž(์—†์œผ๋ฉด None)
904
+ "diameter": diameter_val,
905
+ "degree": degree_val,
906
  }
 
 
907
 
908
+ st.session_state.history.append(new_row)
909
+ # ์ฆ‰์‹œ ๋ฐ˜์˜
910
+ st.success("ํ˜„์žฌ ๊ฒฐ๊ณผ๊ฐ€ ๊ธฐ๋ก์— ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
911
+ st.rerun()
 
 
 
912
 
913
+ # ===== ํ…Œ์ด๋ธ”/๋ฒ„ํŠผ ๋™์ž‘ =====
914
  if st.session_state.history:
915
  df = pd.DataFrame(st.session_state.history)
916
  cols = ["์„ ํƒ"] + [c for c in df.columns if c != "์„ ํƒ"]
917
  df = df[cols]
918
 
919
  if select_all_btn:
920
+ for r in st.session_state.history:
921
+ r["์„ ํƒ"] = True
922
+ st.rerun()
923
 
924
  st.subheader(f"๊ธฐ๋ก ํ…Œ์ด๋ธ” (์ด {len(df)}๊ฑด, ์ฒดํฌ ํ›„ ์‚ญ์ œ ๊ฐ€๋Šฅ)")
925
+ edited_df = st.data_editor(
926
+ df, hide_index=True, use_container_width=True, key="history_editor"
927
+ )
928
 
929
  if delete_btn:
930
  selected_index = edited_df[edited_df["์„ ํƒ"] == True]["Index"].tolist()
931
  st.session_state.history = [
932
+ rec for rec in st.session_state.history if rec.get("Index") not in selected_index
933
  ]
934
  st.success(f"{len(selected_index)}๊ฐœ ํ•ญ๋ชฉ ์‚ญ์ œ ์™„๋ฃŒ!")
935
  st.rerun()
936
+
937
+ # CSV ๋‹ค์šด๋กœ๋“œ(์ „์ฒด/์„ ํƒ)
938
+ sel_df = edited_df[edited_df["์„ ํƒ"] == True].copy()
939
+ c1, c2 = st.columns(2)
940
+ with c1:
941
+ csv_all = edited_df.to_csv(index=False).encode("utf-8-sig")
942
+ st.download_button("CSV (์ „์ฒด ๋‹ค์šด๋กœ๋“œ)", csv_all, "simulation_history_all.csv", "text/csv", use_container_width=True)
943
+ with c2:
944
+ if len(sel_df):
945
+ csv_sel = sel_df.to_csv(index=False).encode("utf-8-sig")
946
+ st.download_button("CSV (์„ ํƒ๋งŒ ๋‹ค์šด๋กœ๋“œ)", csv_sel, "simulation_history_selected.csv", "text/csv", use_container_width=True)
947
+ else:
948
+ st.caption("์„ ํƒ๋œ ํ–‰์ด ์—†์Šต๋‹ˆ๋‹ค. ํ‘œ์—์„œ ์ฒดํฌ ํ›„ ๋‹ค์šด๋กœ๋“œํ•˜์„ธ์š”.")
949
  else:
950
+ st.info("์•„์ง ์ €์žฅ๋œ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค. ๋ฒ”์œ„ ์ž…๋ ฅ์œผ๋กœ ์‹คํ–‰ํ•˜๋ฉด ์กฐ๊ฑด์„ ๋งŒ์กฑํ•œ ๋ชจ๋“  ์กฐํ•ฉ์ด ์ž๋™ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.")