# app_simulation_multi.py # - 직접 입력: 단일 재질/비드 # - 범위 입력: 다중 재질/다중 비드 → 전체 경우 수 생성 후 규칙/스윕 필터 → MAX_FAILURE & THINNING 동시 예측(Blend) from dashboard_theme.theme import inject inject("graphite_gold") import os import itertools import warnings from pathlib import Path from datetime import datetime from decimal import Decimal from typing import Dict, List, Tuple, Union import numpy as np import pandas as pd import streamlit as st st.title("시뮬레이션 실행") warnings.filterwarnings("ignore", category=FutureWarning) # 인증 체크 if "authenticated" not in st.session_state or not st.session_state["authenticated"]: st.error("⛔ 접근 불가: 먼저 메인 화면에서 비밀번호를 입력하세요.") st.stop() # ========================================= # 페이지 설정 & 스타일 # ========================================= def _set_env_from_secrets(key: str): try: val = st.secrets[key] except Exception: val = None if val: os.environ[key] = str(val) _set_env_from_secrets("FS_THIN_ART_DIR") _set_env_from_secrets("FS_MF_ART_DIR") # ---- Compact 모드: 전체 여백/패딩 축소 ---- st.markdown(""" """, unsafe_allow_html=True) # ========================================= # 기본값 & 전역 상태 # ========================================= DEFAULT_RESULT = {"THINNING": 0.65, "MAX_FAILURE": 1.02} DISPLAY_LABELS = ["440", "590", "780"] DISPLAY_TO_MODEL = {"440": "440.0", "590": "590.0", "780": "780.0"} MATERIAL_THICKNESS_CAP = {"440": 0.17, "590": 0.16, "780": 0.10} st.session_state.setdefault("history", []) st.session_state.setdefault("input_mode", "직접 입력") st.session_state.setdefault("material", "590") # 직접 입력 기본값 # ========================================= # 파일 탐색 # ========================================= def _find_file(name: str): here = Path(__file__).parent for p in [here / name, here / "assets" / name, Path.cwd() / name, Path("/mnt/data") / name]: if p.exists(): return str(p) return "" # 필요 시 절대경로로 바꿔도 됨 RULES_XLSX = _find_file("형상별_허용범위정리표.xlsx") SWEEP_XLSX = _find_file("직경별_설계변경허용범위.xlsx") # ========================================= # 조합 생성 & 필터 유틸 # ========================================= def dseq(start: float, stop: float, step: float, q="0.001") -> List[float]: s, e, stp = map(lambda x: Decimal(str(x)), [start, stop, step]) vals, cur = [], s while cur <= e + Decimal("1e-12"): vals.append(float(cur.quantize(Decimal(q)))) cur += stp return vals def bead_to_lr(bead_value: Union[str, None]) -> Tuple[int, int]: mapping = {None:(2,2),"none":(0,0),"right":(0,1),"left":(1,0),"double":(1,1)} key = bead_value.lower() if isinstance(bead_value, str) else bead_value return mapping.get(key, (0, 0)) def make_all_combinations(cfg: Dict) -> pd.DataFrame: bead_values = cfg.get("beads") or [None] bead_info = [(b, *bead_to_lr(b)) for b in bead_values] materials = cfg["materials"] thickness = dseq(cfg["min_thickness"], cfg["max_thickness"], cfg["thickness_step"]) diameter = [int(x) for x in dseq(cfg["min_diameter"], cfg["max_diameter"], cfg["diameter_step"], q="1")] upper_r = dseq(cfg["upper_min"], cfg["upper_max"], cfg["upper_step"]) lower_r = dseq(cfg["lower_min"], cfg["lower_max"], cfg["lower_step"]) degree = [int(x) for x in dseq(cfg["min_degree"], cfg["max_degree"], cfg["degree_step"], q="1")] grid = itertools.product(materials, thickness, upper_r, lower_r, diameter, degree, bead_info) rows = [] for mat, th, ur, lr, dia, deg, bead_t in grid: bead_name, lb, rb = bead_t rows.append((mat, th, ur, lr, dia, deg, bead_name, lb, rb)) df = pd.DataFrame( rows, columns=["material", "thickness", "upper_radius", "lower_radius", "diameter", "degree", "bead", "LB", "RB"] ) return df # ----- Sweep 한계 ----- def build_limit_dicts(df_sweep: pd.DataFrame): t = df_sweep.copy().replace("F", np.nan) if "Sweep" in t.columns: t = t.set_index("Sweep") new_cols = [] for c in t.columns: try: new_cols.append(int(c)) except: new_cols.append(c) t.columns = new_cols long = t.stack(dropna=False).reset_index() long.columns = ["row", "degree", "limit"] tmp = long["row"].str.extract(r"(?P\d+)_(?Pupper|lower)_radius") long = pd.concat([long, tmp], axis=1) long["diameter"] = pd.to_numeric(long["diameter"], errors="coerce") long["degree"] = pd.to_numeric(long["degree"], errors="coerce") long["limit"] = pd.to_numeric(long["limit"], errors="coerce") upper = long[long["which"]=="upper"].dropna(subset=["diameter","degree"]) lower = long[long["which"]=="lower"].dropna(subset=["diameter","degree"]) upper_dict = {(int(d), int(g)): v for d, g, v in zip(upper["diameter"], upper["degree"], upper["limit"]) } lower_dict = {(int(d), int(g)): v for d, g, v in zip(lower["diameter"], lower["degree"], lower["limit"]) } return upper_dict, lower_dict def filter_grid_by_sweep_limits(df_grid: pd.DataFrame, df_sweep: pd.DataFrame) -> pd.DataFrame: upper_dict, lower_dict = build_limit_dicts(df_sweep) key = list(zip(df_grid["diameter"].astype(int), df_grid["degree"].astype(int))) df_grid = df_grid.copy() df_grid["limit_upper"] = [upper_dict.get(k, np.nan) for k in key] df_grid["limit_lower"] = [lower_dict.get(k, np.nan) for k in key] not_nan = df_grid["limit_upper"].notna() & df_grid["limit_lower"].notna() within = (df_grid["upper_radius"] <= df_grid["limit_upper"]) & \ (df_grid["lower_radius"] <= df_grid["limit_lower"]) return df_grid[not_nan & within].reset_index(drop=True) # ----- 규칙 시트 ----- SHEET_BY_BEAD = {"left":"left", "right":"right", "double":"both", "none":"none"} def _normalize_input_df(df: pd.DataFrame) -> pd.DataFrame: df2 = df.copy() df2.columns = [str(c).strip() for c in df2.columns] rename = {} for c in df2.columns: lc = c.lower().strip() if lc == "diamater": rename[c] = "diameter" elif lc in ("material","diameter","degree","bead"): rename[c] = lc df2 = df2.rename(columns=rename) need = {"material","diameter","degree","bead"} missing = need - set(df2.columns) if missing: raise ValueError(f"입력 데이터프레임에 필요한 컬럼이 없습니다: {missing}") df2["bead"] = df2["bead"].astype(str).str.strip().str.lower() for c in ["material","diameter","degree"]: df2[c] = pd.to_numeric(df2[c], errors="coerce") return df2.dropna(subset=["material","diameter","degree"]).copy() def _read_rule_sheet(xlsx_path: str, sheet_name: str) -> pd.DataFrame: rule = pd.read_excel(xlsx_path, sheet_name=sheet_name) rule.columns = rule.columns.str.strip().str.lower() rule = rule.rename(columns={"diamater":"diameter"}) need = {"material","diameter","min_degree","max_degree"} missing = need - set(rule.columns) if missing: raise ValueError(f"규칙 시트 '{sheet_name}'에 필요한 컬럼이 없습니다: {missing}") for c in need: rule[c] = pd.to_numeric(rule[c], errors="coerce") rule = rule.dropna(subset=list(need)).copy() rule = rule.astype({"material":"int64","diameter":"int64"}) return rule[["material","diameter","min_degree","max_degree"]] def _apply_rules(df_part: pd.DataFrame, rule: pd.DataFrame) -> pd.DataFrame: if df_part.empty: return df_part.copy() df_part = df_part[df_part["material"].isin(rule["material"].unique())].copy() if df_part.empty: return df_part merged = df_part.merge(rule, on=["material","diameter"], how="left") mask = ( merged["min_degree"].notna() & merged["max_degree"].notna() & (merged["degree"] >= merged["min_degree"]) & (merged["degree"] <= merged["max_degree"]) ) return merged.loc[mask, df_part.columns].reset_index(drop=True) def filter_all_by_bead(df: pd.DataFrame, rules_xlsx: str) -> pd.DataFrame: base = _normalize_input_df(df) outs = [] for bead_value, sheet in SHEET_BY_BEAD.items(): part = base[base["bead"] == bead_value].copy() if part.empty: continue rule = _read_rule_sheet(rules_xlsx, sheet) outs.append(_apply_rules(part, rule)) if not outs: return base.iloc[0:0].copy() return pd.concat(outs, axis=0, ignore_index=True).reset_index(drop=True) # ========================================= # 예측기 (두 타깃 동시) # ========================================= import torch import torch.nn as nn import lightgbm as lgb ART_DIR_MF_DEFAULT = "artifacts_blend" ART_DIR_THIN_DEFAULT = "artifacts_blend_thinning" CAT_COL_DEFAULT = "material" NUM_COLS_DEFAULT = ["thickness","diameter","degree","upper_radius","lower_radius","LB","RB"] class FTTransformer(nn.Module): def __init__(self, n_materials:int, n_num:int, d_model:int=192, nhead:int=8, num_layers:int=4, dim_ff:int=768, dropout:float=0.15): super().__init__() self.mat_emb = nn.Embedding(n_materials, d_model) self.num_linears = nn.ModuleList([nn.Linear(1, d_model) for _ in range(n_num)]) self.cls = nn.Parameter(torch.zeros(1, 1, d_model)) nn.init.trunc_normal_(self.cls, std=0.02) enc_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=dim_ff, dropout=dropout, batch_first=True, activation='gelu', norm_first=True ) self.encoder = nn.TransformerEncoder(enc_layer, num_layers=num_layers) self.head = nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, d_model), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_model, 1)) def forward(self, mat_ids, x_num): B = x_num.size(0) mat_tok = self.mat_emb(mat_ids).unsqueeze(1) num_tok = torch.cat([lin(x_num[:, i:i+1]).unsqueeze(1) for i, lin in enumerate(self.num_linears)], dim=1) tokens = torch.cat([self.cls.expand(B, -1, -1), mat_tok, num_tok], dim=1) h = self.encoder(tokens) return self.head(h[:, 0, :]) def _scale_like_fold(X_num: np.ndarray, mean: np.ndarray, scale: np.ndarray) -> np.ndarray: return ((X_num - mean) / scale).astype(np.float32) def _canonize_list(materials): return [str(m).strip() for m in materials] def _build_alias2canon(canon_list): alias2canon = {} for c in canon_list: alias2canon[c] = c s = c.strip(); alias2canon[s] = c if "." in s: alias2canon[s.rstrip("0").rstrip(".")] = c try: v = float(s); alias2canon[str(v)] = c if v.is_integer(): alias2canon[str(int(v))] = c except: pass return alias2canon def _first_existing(*paths): for p in paths: if os.path.exists(p): return p return None def _load_json_like(art_dir: str, basename: str) -> dict: p1 = os.path.join(art_dir, f"{basename}.json"); p2 = os.path.join(art_dir, basename) p = _first_existing(p1, p2) if p is None: raise FileNotFoundError(f"Missing {basename}(.json) in {self.art_dir}") import json; return json.load(open(p, "r", encoding="utf-8")) def _load_columns_meta(art_dir: str): p = _first_existing(os.path.join(art_dir, "columns_thinning.json"), os.path.join(art_dir, "columns.json")) if not p: return None import json; return json.load(open(p, "r", encoding="utf-8")) class _SingleTargetBlendPredictor: def __init__(self, art_dir:str, lgbm_prefix:str, ftt_prefix:str, alpha_json:str, cat_col_default:str=CAT_COL_DEFAULT, num_cols_default:List[str]=None, allow_columns_meta:bool=False, unknown_policy:str="error"): self.art_dir = art_dir; self.lgbm_prefix=lgbm_prefix; self.ftt_prefix=ftt_prefix self.alpha_json=alpha_json; self.unknown_policy=unknown_policy self.cat_col = cat_col_default; self.num_cols = list(num_cols_default or NUM_COLS_DEFAULT) if allow_columns_meta: meta = _load_columns_meta(art_dir) if meta: self.cat_col = meta.get("cat_col", self.cat_col); self.num_cols = meta.get("num_cols", self.num_cols) self.folds_ft = self._load_ft_folds() self.boosters = self._load_lgbm_folds() self.materials = self._load_materials() self.best_alpha = float(_load_json_like(art_dir, self.alpha_json)["best_alpha"]) self.materials_canon = _canonize_list(self.materials) self.alias2canon = _build_alias2canon(self.materials_canon) self.mat2id = {m:i for i,m in enumerate(self.materials_canon)} def _load_ft_folds(self): folds=[] for fold in range(1,11): p = os.path.join(self.art_dir, f"{self.ftt_prefix}{fold}.pt") if not os.path.exists(p): if folds: break continue ckpt = torch.load(p, map_location="cpu", weights_only=False) model = FTTransformer(len(ckpt["materials"]), len(ckpt["num_cols"])) model.load_state_dict(ckpt["state_dict"]); model.eval() folds.append({"model":model,"materials":ckpt["materials"],"num_cols":ckpt["num_cols"], "scaler_mean":np.array(ckpt["scaler_mean"],dtype=np.float32), "scaler_scale":np.array(ckpt["scaler_scale"],dtype=np.float32)}) if not folds: raise FileNotFoundError(f"No FT checkpoints found in {self.art_dir} (prefix={self.ftt_prefix})") return folds def _load_lgbm_folds(self): boosters=[] for fold in range(1,11): p = _first_existing(os.path.join(self.art_dir,f"{self.lgbm_prefix}{fold}.txt"), os.path.join(self.art_dir,f"{self.lgbm_prefix}{fold}")) if p is None: if boosters: break continue boosters.append(lgb.Booster(model_file=p)) if not boosters: raise FileNotFoundError(f"No LightGBM model files found in {self.art_dir} (prefix={self.lgbm_prefix})") return boosters def _load_materials(self): try: return _load_json_like(self.art_dir,"materials")["materials"] except FileNotFoundError: return self.folds_ft[0]["materials"] def _prep_df(self, df_new: pd.DataFrame) -> pd.DataFrame: df = df_new.copy() need = [self.cat_col] + self.num_cols missing = [c for c in need if c not in df.columns] if missing: raise ValueError(f"Missing columns in input: {missing}") df[self.cat_col] = df[self.cat_col].astype(str).str.strip() df["_mat_canon"] = df[self.cat_col].map(self.alias2canon) if self.unknown_policy == "error": unknown = df.loc[df["_mat_canon"].isna(), self.cat_col].unique().tolist() if unknown: raise ValueError(f"Unknown materials in input {unknown}. Known materials: {self.materials_canon[:10]}{' ...' if len(self.materials_canon)>10 else ''}") df["_mat_id"] = df["_mat_canon"].map(self.mat2id).astype(int) else: df["_mat_canon"] = df["_mat_canon"].fillna(self.materials_canon[0]) df["_mat_id"] = df["_mat_canon"].map(self.mat2id).astype(int) df[self.num_cols] = df[self.num_cols].apply(pd.to_numeric, errors="coerce") if df[self.num_cols].isnull().any().any(): bad = df[self.num_cols].columns[df[self.num_cols].isnull().any()].tolist() raise ValueError(f"Non-numeric values detected in columns: {bad}") return df def predict_ft(self, df_new: pd.DataFrame) -> np.ndarray: df = self._prep_df(df_new); mids = torch.tensor(df["_mat_id"].values, dtype=torch.long) preds=[] for f in self.folds_ft: Xn = df[f["num_cols"]].values.astype(np.float32) x_scaled = _scale_like_fold(Xn, f["scaler_mean"], f["scaler_scale"]) with torch.no_grad(): p = f["model"](mids, torch.tensor(x_scaled, dtype=torch.float32)).cpu().numpy().ravel() preds.append(p) return np.mean(preds, axis=0) def predict_lgbm(self, df_new: pd.DataFrame) -> np.ndarray: df = self._prep_df(df_new) X = df[[self.cat_col] + self.num_cols].copy() X[self.cat_col] = pd.Categorical(df["_mat_canon"], categories=self.materials_canon) preds = [bst.predict(X, num_iteration=getattr(bst,"best_iteration",None)) for bst in self.boosters] return np.mean(preds, axis=0) def predict_blend(self, df_new: pd.DataFrame, alpha: float|None=None) -> np.ndarray: alpha = self.best_alpha if alpha is None else alpha return alpha * self.predict_ft(df_new) + (1 - alpha) * self.predict_lgbm(df_new) class MultiTargetBlendPredictor: def __init__(self, art_dir_mf:str, art_dir_thin:str, unknown_policy:str="error"): self.mf = _SingleTargetBlendPredictor(art_dir=art_dir_mf, lgbm_prefix="lgbm_fold", ftt_prefix="ftt_fold", alpha_json="blend_alpha", allow_columns_meta=False, unknown_policy=unknown_policy) self.thin = _SingleTargetBlendPredictor(art_dir=art_dir_thin, lgbm_prefix="lgbm_thinning_fold", ftt_prefix="ftt_thinning_fold", alpha_json="blend_alpha_thinning", allow_columns_meta=True, unknown_policy=unknown_policy) def predict_both(self, df_new: pd.DataFrame, alpha_mf: float|None=None, alpha_th: float|None=None): return { "blend_max_failure": self.mf.predict_blend(df_new, alpha_mf), "blend_thinning": self.thin.predict_blend(df_new, alpha_th), "lgbm_max_failure": self.mf.predict_blend(df_new, 0.0), "dl_max_failure": self.mf.predict_blend(df_new, 1.0), "lgbm_thinning": self.thin.predict_blend(df_new, 0.0), "dl_thinning": self.thin.predict_blend(df_new, 1.0), } @st.cache_resource(show_spinner=False) def get_predictor(): art_mf = os.environ.get("FS_MF_ART_DIR", ART_DIR_MF_DEFAULT) art_th = os.environ.get("FS_THIN_ART_DIR", ART_DIR_THIN_DEFAULT) return MultiTargetBlendPredictor(art_dir_mf=art_mf, art_dir_thin=art_th, unknown_policy="fallback0") def predict_both_blend(df: pd.DataFrame): out = get_predictor().predict_both(df) return out["blend_max_failure"], out["blend_thinning"] # ========================================= # UI 유틸 # ========================================= def bead_to_flags_ui(bead: str): if bead == "Left Bead": return 1,0 if bead == "Right Bead": return 0,1 if bead == "Double Bead": return 1,1 return 0,0 def _bead_key_from_label(label: str) -> str: return {"No Bead":"none","Left Bead":"left","Right Bead":"right","Double Bead":"double"}[label] # (업그레이드) 아이콘 지원 Metric 카드 def metric_card(label: str, value: float, lo: float, hi: float, icon: str = "📉"): ok = lo <= float(value) <= hi cls = "ok" if ok else "bad" status_text = "정상" if ok else "범위 밖" st.markdown( f"""
{icon} {label}
{float(value):.3f}
{status_text}
허용범위: {lo:.2f} ~ {hi:.2f}
""", unsafe_allow_html=True ) def val_or_range(single_key, range_key, unit=""): mode = st.session_state.get("input_mode", "직접 입력") if mode == "범위 값 입력" and range_key in st.session_state: lo, hi = st.session_state[range_key] return f"{lo}{unit} ~ {hi}{unit}" elif mode == "직접 입력" and single_key in st.session_state: return f"{st.session_state[single_key]}{unit}" return "-" def render_cap_table(): with st.expander("상한 기준표 보기", expanded=False): st.markdown("""
재질(표기)모델 라벨두께 감소율(허용 상한)
440440.00.17
590590.00.16
780780.00.10
""", unsafe_allow_html=True) def build_df_single(material_display, thickness, diameter, degree, upperR, lowerR, beadType): lb, rb = bead_to_flags_ui(beadType) mat_model = DISPLAY_TO_MODEL[material_display] return pd.DataFrame([{ "material": mat_model, "thickness": float(thickness), "diameter": int(diameter), "degree": int(degree), "upper_radius": float(upperR), "lower_radius": float(lowerR), "LB": int(lb), "RB": int(rb), }]) @st.cache_resource(show_spinner=False) def get_sweep_df(): return pd.read_excel(SWEEP_XLSX, sheet_name=0) if SWEEP_XLSX else None @st.cache_resource(show_spinner=False) def get_sweep_dicts(): df = get_sweep_df() if df is None: return {}, {} return build_limit_dicts(df) def validate_direct_input(material, diameter, degree, bead_label, upperR, lowerR): if RULES_XLSX: bead_key = _bead_key_from_label(bead_label) rule = _read_rule_sheet(RULES_XLSX, SHEET_BY_BEAD[bead_key]) row = rule[(rule["material"] == int(material)) & (rule["diameter"] == int(diameter))] if not row.empty: r = row.iloc[0]; mn, mx = int(r["min_degree"]), int(r["max_degree"]) if not (mn <= int(degree) <= mx): return False, f"각도 {degree}°는 규칙 범위({mn}~{mx}°) 밖입니다." else: return True, None upper_dict, lower_dict = get_sweep_dicts() key = (int(diameter), int(degree)) u = upper_dict.get(key, np.nan); l = lower_dict.get(key, np.nan) if np.isnan(u) or np.isnan(l): return False, "스윕표에 없는 직경/각도 조합입니다." if float(upperR) > float(u) or float(lowerR) > float(l): return False, f"R 한계 초과: 상단R ≤ {u}, 하단R ≤ {l} 이어야 합니다." return True, None def _reset_optimum_summary(): for k in ["best_filter_thin","best_filter_mf","best_all_thin","best_all_mf"]: st.session_state.pop(k, None) st.session_state.pop("topcard_source", None) # ========================================= # 탭 UI # ========================================= tabs = st.tabs(["조건 설정 & 실행", "결과 시각화", "기록 조회"]) # ----------------------------------------- # 1) 조건 설정 & 실행 # ----------------------------------------- with tabs[0]: st.header("조건 설정") st.markdown("---") st.subheader("입력 모드") st.session_state["input_mode"] = st.radio("입력 방식", ["직접 입력", "범위 값 입력"], horizontal=True, label_visibility="collapsed", key="mode_radio") _prev_mode = st.session_state.get("_prev_input_mode") if _prev_mode is not None and _prev_mode != st.session_state["input_mode"]: _reset_optimum_summary() st.session_state["_prev_input_mode"] = st.session_state["input_mode"] col_b1, col_b2 = st.columns(2) if st.session_state["input_mode"] == "직접 입력": with col_b1: st.session_state["beadType"] = st.selectbox("비드 타입 선택", ["No Bead","Double Bead","Left Bead","Right Bead"]) with col_b2: cur = st.session_state.get("material", "590") idx = DISPLAY_LABELS.index(cur) if cur in DISPLAY_LABELS else 1 st.session_state["material"] = st.selectbox("재질", DISPLAY_LABELS, index=idx) else: with col_b1: st.session_state["beadTypes_multi"] = st.multiselect( "비드 타입 선택 (복수 가능)", ["No Bead","Double Bead","Left Bead","Right Bead"], default=["Right Bead"]) with col_b2: default_materials = [st.session_state.get("material","590")] st.session_state["materials_multi"] = st.multiselect( "재질 (복수 가능)", DISPLAY_LABELS, default=default_materials) st.subheader("성형 조건") st.markdown("
", unsafe_allow_html=True) if st.session_state["input_mode"] == "직접 입력": for key in ["diameterRange","degreeRange","upperRRange","lowerRRange","thicknessRange"]: st.session_state.pop(key, None) col_t, col_d = st.columns(2) with col_t: st.session_state["thickness"] = st.selectbox("소재 두께 (mm)", [0.7,0.8,0.9,1.0,1.1,1.2], index=2) with col_d: st.session_state["diameter"] = st.number_input("직경 (mm)", 10, 1000, 20) col1, col2 = st.columns(2) with col1: st.session_state["upperR"] = st.number_input("상단 R", 1, 100, 4) with col2: st.session_state["lowerR"] = st.number_input("하단 R", 1, 100, 3) st.session_state["degree"] = st.number_input("각도 (°)", 0, 90, 75) if st.button("시뮬레이션 실행하기", use_container_width=True, type="primary"): _reset_optimum_summary() ok, err = validate_direct_input( st.session_state["material"], st.session_state["diameter"], st.session_state["degree"], st.session_state["beadType"], st.session_state["upperR"], st.session_state["lowerR"] ) if not ok: st.error(f"규칙 위반: {err}") st.stop() df = build_df_single( st.session_state["material"], st.session_state["thickness"], st.session_state["diameter"], st.session_state["degree"], st.session_state["upperR"], st.session_state["lowerR"], st.session_state["beadType"], ) try: with st.spinner("모델 예측 중입니다…"): mf_arr, th_arr = predict_both_blend(df) mf = float(mf_arr[0]); th = float(th_arr[0]) except Exception as e: st.error(f"모델 예측 실패: {e}"); st.stop() st.session_state.sim_result = {"THINNING": th, "MAX_FAILURE": mf} st.session_state.topcard_source = "single" st.success("✅ 시뮬레이션 완료! (Blend 모델 사용)") else: for key in ["diameter","degree","upperR","lowerR","thickness"]: st.session_state.pop(key, None) col_t, col_d = st.columns(2) with col_t: st.session_state["thicknessRange"] = st.slider("소재 두께 범위 (mm)", 0.7, 1.2, (0.7, 1.0), step=0.1) with col_d: st.session_state["diameterRange"] = st.slider("직경 범위 (mm)", 10, 50, (15, 30)) col1, col2 = st.columns(2) with col1: st.session_state["upperRRange"] = st.slider("상단 R 범위", 1, 15, (3, 7)) with col2: st.session_state["lowerRRange"] = st.slider("하단 R 범위", 1, 10, (2, 4)) st.session_state["degreeRange"] = st.slider("각도 범위 (°)", 60, 90, (72, 87)) st.divider() st.subheader("결과 필터 조건 (선택)") st.session_state["apply_post_filter"] = st.checkbox("결과 필터 적용 (THINNING ≤, MAX FAILURE ≤)", value=False) selected_mats = st.session_state.get("materials_multi", []) or DISPLAY_LABELS caps = [MATERIAL_THICKNESS_CAP[m] for m in selected_mats] default_thin_cap = float(min(caps)) if len(caps) else 0.16 f2, f3 = st.columns([1,1]) with f2: st.session_state.setdefault("filter_thinning_max", default_thin_cap) st.session_state["filter_thinning_max"] = st.number_input( "THINNING ≤", 0.0, 1.0, float(st.session_state["filter_thinning_max"]), step=0.01, format="%.2f", disabled=not st.session_state["apply_post_filter"] ) with f3: st.session_state.setdefault("filter_max_failure_max", 1.0) st.session_state["filter_max_failure_max"] = st.number_input( "MAX FAILURE ≤", 0.0, 2.0, float(st.session_state["filter_max_failure_max"]), step=0.01, format="%.2f", disabled=not st.session_state["apply_post_filter"] ) if st.button("시뮬레이션 실행하기", use_container_width=True, type="primary"): _reset_optimum_summary() bead_keys = [_bead_key_from_label(b) for b in (st.session_state.get("beadTypes_multi") or ["Right Bead"])] mats_disp = st.session_state.get("materials_multi") or DISPLAY_LABELS mats_int = [int(m) for m in mats_disp] cfg = { "materials": mats_int, "min_thickness": st.session_state["thicknessRange"][0], "max_thickness": st.session_state["thicknessRange"][1], "thickness_step": 0.1, "min_diameter": st.session_state["diameterRange"][0], "max_diameter": st.session_state["diameterRange"][1], "diameter_step": 1, "upper_min": st.session_state["upperRRange"][0], "upper_max": st.session_state["upperRRange"][1], "upper_step": 1.0, "lower_min": st.session_state["lowerRRange"][0], "lower_max": st.session_state["lowerRRange"][1], "lower_step": 1.0, "min_degree": st.session_state["degreeRange"][0], "max_degree": st.session_state["degreeRange"][1], "degree_step": 1, "beads": bead_keys, } df_all = make_all_combinations(cfg) if RULES_XLSX: df_all = filter_all_by_bead(df_all, RULES_XLSX) else: st.warning("규칙 파일을 찾지 못했습니다. (형상별_허용범위정리표.xlsx)") sweep_df = get_sweep_df() if sweep_df is not None and not df_all.empty: df_all = filter_grid_by_sweep_limits(df_all, sweep_df) elif sweep_df is None: st.warning("스윕 한계 파일을 찾지 못했습니다. (직경별_설계변경허용범위.xlsx)") if df_all.empty: st.warning("규칙/스윕 한계 적용 후 남는 조합이 없습니다.") st.stop() pred_df = df_all.copy() pred_df["material"] = pred_df["material"].astype(int).astype(str).map(DISPLAY_TO_MODEL) try: with st.spinner("모델 예측 중입니다…"): mf_pred, th_pred = predict_both_blend(pred_df) except Exception as e: st.error(f"모델 예측 실패: {e}"); st.stop() df_all["THINNING"] = th_pred df_all["MAX_FAILURE"] = mf_pred if st.session_state.get("apply_post_filter", False): thin_thr = float(st.session_state["filter_thinning_max"]) mf_thr = float(st.session_state["filter_max_failure_max"]) ok_mask = (df_all["THINNING"] <= thin_thr) & (df_all["MAX_FAILURE"] <= mf_thr) matched = df_all.loc[ok_mask].copy() else: matched = df_all.copy() total = len(df_all) def _best_rows(df_ok: pd.DataFrame, df_all: pd.DataFrame): best = {"filter_thin": None, "filter_mf": None, "all_thin": None, "all_mf": None} if len(df_ok): best["filter_thin"] = df_ok.loc[df_ok["THINNING"].idxmin()] best["filter_mf"] = df_ok.loc[df_ok["MAX_FAILURE"].idxmin()] best["all_thin"] = df_all.loc[df_all["THINNING"].idxmin()] best["all_mf"] = df_all.loc[df_all["MAX_FAILURE"].idxmin()] return best best = _best_rows(matched if len(matched) else df_all, df_all) st.session_state.best_filter_thin = None if matched.empty else best["filter_thin"].to_dict() st.session_state.best_filter_mf = None if matched.empty else best["filter_mf"].to_dict() st.session_state.best_all_thin = best["all_thin"].to_dict() st.session_state.best_all_mf = best["all_mf"].to_dict() if len(matched) and st.session_state.get("apply_post_filter", False): st.session_state.sim_result = { "THINNING": float(best["filter_thin"]["THINNING"]), "MAX_FAILURE": float(best["filter_mf"]["MAX_FAILURE"]), } st.session_state.topcard_source = "filter" else: st.session_state.sim_result = { "THINNING": float(best["all_thin"]["THINNING"]), "MAX_FAILURE": float(best["all_mf"]["MAX_FAILURE"]), } st.session_state.topcard_source = "overall" if len(matched): now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") matched = matched.copy() matched["선택"] = False matched["Index"] = None matched["저장시각"] = now_str matched["재질"] = matched["material"].astype(int).astype(str) start_idx = len(st.session_state.history) for i in range(len(matched)): matched.at[matched.index[i],"Index"] = start_idx + i + 1 matched = matched[[ "선택","Index","저장시각","bead", "thickness","재질","diameter","degree","upper_radius","lower_radius", "THINNING","MAX_FAILURE" ]].rename(columns={ "bead":"비드 타입", "thickness":"소재 두께 (mm)", "upper_radius":"상단 R", "lower_radius":"하단 R", }) st.session_state.history.extend(matched.to_dict("records")) if st.session_state.get("apply_post_filter", False): st.success(f"✅ 총 {total}개 조합 중 **필터를 만족한 {len(matched)}개**를 기록에 추가했습니다.") else: st.success(f"✅ 필터 미적용: **총 {len(matched)}개 전체 조합**을 기록에 추가했습니다.") else: if st.session_state.get("apply_post_filter", False): st.warning(f"필터 조건을 만족하는 조합이 없습니다. (총 {total}개 조합)") else: st.warning("남는 조합이 없습니다.") # ----------------------------------------- # 2) 결과 시각화 # ----------------------------------------- with tabs[1]: st.header("결과 시각화") # (선택) 이 탭에서 hr/st.divider를 숨기고 싶으면 주석 해제 # st.markdown(""" # #
""", unsafe_allow_html=True) result_data = st.session_state.get("sim_result", DEFAULT_RESULT) src = st.session_state.get("topcard_source", "") if src == "filter": st.caption("카드 값: **필터 내 최적** (THINNING 최소 / MAX FAILURE 최소)") elif src == "overall": st.caption("카드 값: **전체 탐색 최적** (THINNING 최소 / MAX FAILURE 최소)") elif src == "single": st.caption("카드 값: **단일 입력 결과**") col1, col2 = st.columns(2, gap="small") with col1: st.session_state.setdefault("material", "590") cur_mat = st.session_state["material"] thin_cap = MATERIAL_THICKNESS_CAP.get(cur_mat, 0.16) st.session_state["thinning_min"] = 0.0 st.session_state["thinning_max"] = thin_cap metric_card("THINNING (두께 감소율)", result_data.get("THINNING", 0.0), float(st.session_state["thinning_min"]), float(st.session_state["thinning_max"])) with col2: st.session_state.setdefault("max_failure_min", 0.00) st.session_state.setdefault("max_failure_max", 0.97) metric_card("MAX FAILURE", result_data.get("MAX_FAILURE", 0.0), float(st.session_state["max_failure_min"]), float(st.session_state["max_failure_max"])) cL, cR = st.columns(2, gap="small") def _summary_block(title: str, row_thin: pd.Series, row_mf: pd.Series): st.subheader(title) st.markdown("**THINNING 최소**") if row_thin is not None and len(row_thin): lb, rb = int(row_thin.get('LB',0)), int(row_thin.get('RB',0)) bead_label = ("No Bead" if (lb==0 and rb==0) else ("Left Bead" if (lb==1 and rb==0) else ("Right Bead" if (lb==0 and rb==1) else "Double Bead"))) st.markdown( f"- 재질: **{row_thin.get('material','-')}** (비드: **{bead_label}**)
" f"- 두께: **{row_thin.get('thickness','-')} mm**, 직경: **{row_thin.get('diameter','-')} mm**, 각도: **{row_thin.get('degree','-')}°**
" f"- 상단 R: **{row_thin.get('upper_radius','-')}**, 하단 R: **{row_thin.get('lower_radius','-')}**
" f"- **THINNING: {row_thin.get('THINNING',0):.3f}**, MAX_FAILURE: {row_thin.get('MAX_FAILURE',0):.3f}", unsafe_allow_html=True) else: st.caption("해당 없음") st.markdown("---") st.markdown("**MAX_FAILURE 최소**") if row_mf is not None and len(row_mf): lb, rb = int(row_mf.get('LB',0)), int(row_mf.get('RB',0)) bead_label = ("No Bead" if (lb==0 and rb==0) else ("Left Bead" if (lb==1 and rb==0) else ("Right Bead" if (lb==0 and rb==1) else "Double Bead"))) st.markdown( f"- 재질: **{row_mf.get('material','-')}** (비드: **{bead_label}**)
" f"- 두께: **{row_mf.get('thickness','-')} mm**, 직경: **{row_mf.get('diameter','-')} mm**, 각도: **{row_mf.get('degree','-')}°**
" f"- 상단 R: **{row_mf.get('upper_radius','-')}**, 하단 R: **{row_mf.get('lower_radius','-')}**
" f"- THINNING: {row_mf.get('THINNING',0):.3f}, **MAX_FAILURE: {row_mf.get('MAX_FAILURE',0):.3f}**", unsafe_allow_html=True) else: st.caption("해당 없음") with cL: _summary_block( "필터 내 최적", None if st.session_state.get("best_filter_thin") is None else pd.Series(st.session_state["best_filter_thin"]), None if st.session_state.get("best_filter_mf") is None else pd.Series(st.session_state["best_filter_mf"]), ) with cR: _summary_block( "전체 탐색 최적", pd.Series(st.session_state.get("best_all_thin", {})) if st.session_state.get("best_all_thin") else None, pd.Series(st.session_state.get("best_all_mf", {})) if st.session_state.get("best_all_mf") else None, ) # ✅ 기준 재질 & 상한표 — 같은 위치(한 열)에 세로로 정렬 with st.container(): material_list = DISPLAY_LABELS cur = st.session_state.get("material", "590") default_idx = material_list.index(cur) if cur in material_list else 1 sel = st.selectbox("기준 재질", material_list, index=default_idx, key="material_for_result") st.session_state["material"] = sel cap = MATERIAL_THICKNESS_CAP[sel] st.session_state["thinning_min"] = 0.0 st.session_state["thinning_max"] = cap st.caption(f"현재 기준 재질: {sel} (두께 감소율 상한 {cap:.2f})") # 바로 아래에 상한 기준표를 붙여서 표시 st.markdown('
', unsafe_allow_html=True) st.caption("재질별 두께 감소율 상한") render_cap_table() st.markdown('
', unsafe_allow_html=True) # (선택) 위에서 열었으면 닫기 # st.markdown("
", unsafe_allow_html=True) # ----------------------------------------- # 3) 기록 조회 ✅ 전체 교체 # ----------------------------------------- with tabs[2]: st.header("기록 조회") col1, col2, col3 = st.columns([1, 1, 1]) with col1: save_btn = st.button("현재 결과 저장", type="primary", use_container_width=True) with col2: select_all_btn = st.button("전체 선택", use_container_width=True) with col3: delete_btn = st.button("선택 항목 삭제", use_container_width=True) # ===== 현재 결과 저장 처리 ===== if save_btn: if "sim_result" not in st.session_state: st.warning("먼저 시뮬레이션을 실행하세요.") else: # 다음 인덱스 try: next_idx = max([r.get("Index", 0) for r in st.session_state.history]) + 1 if st.session_state.history else 1 except Exception: next_idx = len(st.session_state.history) + 1 # 입력 모드/라벨 input_mode = st.session_state.get("input_mode", "직접 입력") if input_mode == "직접 입력": bead_label = st.session_state.get("beadType", "-") material_label = st.session_state.get("material", "-") else: bead_label = ", ".join(st.session_state.get("beadTypes_multi", [])) or "-" material_label = ", ".join(st.session_state.get("materials_multi", [])) or "-" # 단일 값도 있으면 함께 저장(표에서 필터/정렬 편하게) diameter_val = st.session_state.get("diameter") degree_val = st.session_state.get("degree") new_row = { "선택": False, "Index": next_idx, "저장시각": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "비드 타입": bead_label, "입력 방식": input_mode, "소재 두께 (mm)": val_or_range("thickness", "thicknessRange", " mm"), "재질": material_label, "직경": val_or_range("diameter", "diameterRange", " mm"), "각도": val_or_range("degree", "degreeRange", "°"), "상단 R": val_or_range("upperR", "upperRRange"), "하단 R": val_or_range("lowerR", "lowerRRange"), "THINNING": float(st.session_state.sim_result.get("THINNING")), "MAX_FAILURE": float(st.session_state.sim_result.get("MAX_FAILURE")), # 참고용 원시 숫자(없으면 None) "diameter": diameter_val, "degree": degree_val, } st.session_state.history.append(new_row) # 즉시 반영 st.success("현재 결과가 기록에 저장되었습니다.") st.rerun() # ===== 테이블/버튼 동작 ===== if st.session_state.history: df = pd.DataFrame(st.session_state.history) cols = ["선택"] + [c for c in df.columns if c != "선택"] df = df[cols] if select_all_btn: for r in st.session_state.history: r["선택"] = True st.rerun() st.subheader(f"기록 테이블 (총 {len(df)}건, 체크 후 삭제 가능)") edited_df = st.data_editor( df, hide_index=True, use_container_width=True, key="history_editor" ) if delete_btn: selected_index = edited_df[edited_df["선택"] == True]["Index"].tolist() st.session_state.history = [ rec for rec in st.session_state.history if rec.get("Index") not in selected_index ] st.success(f"{len(selected_index)}개 항목 삭제 완료!") st.rerun() # CSV 다운로드(전체/선택) sel_df = edited_df[edited_df["선택"] == True].copy() c1, c2 = st.columns(2) with c1: csv_all = edited_df.to_csv(index=False).encode("utf-8-sig") st.download_button("CSV (전체 다운로드)", csv_all, "simulation_history_all.csv", "text/csv", use_container_width=True) with c2: if len(sel_df): csv_sel = sel_df.to_csv(index=False).encode("utf-8-sig") st.download_button("CSV (선택만 다운로드)", csv_sel, "simulation_history_selected.csv", "text/csv", use_container_width=True) else: st.caption("선택된 행이 없습니다. 표에서 체크 후 다운로드하세요.") else: st.info("아직 저장된 기록이 없습니다. 범위 입력으로 실행하면 조건을 만족한 모든 조합이 자동 저장됩니다.")