lscsc/Process_dash_pub
0
1# -*- coding: utf-8 -*-2 3import streamlit as st4import plotly.graph_objects as go5import pandas as pd6import numpy as np7import warnings8import platform9import plotly.express as px10from scipy import stats11import streamlit.components.v1 as components12from datetime import datetime, timedelta13import os14import logging15from pathlib import Path16import json17from functools import lru_cache18import base64 # 이미지 인코딩을 위해 추가19import re # 정규식 처리를 위해 추가20 21# 로깅 설정22logging.basicConfig(23 level=logging.INFO,24 format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'25)26logger = logging.getLogger('품질대시보드')27 28# 상수 정의29CONFIG = {30 "APP_TITLE": "품질 공정정보 대시보드",31 "APP_ICON": "📊",32 "PASSWORD": "1234",33 "VERSION": "1.0.2", # 버전 업데이트34 "LAST_UPDATE": "2025-03-19", # 날짜 업데이트35 "DATA_PATHS": {36 "process_info": "process_info/process_info.xlsx",37 "quality_issues": "quality_issues/quality_issues.xlsx",38 "process_specs": "process_specs/process_specs.xlsx",39 "process_capability": "process_capability/process_capability.xlsx",40 "defect_rates": "defect_rates/defect_rates.xlsx",41 "customer_info": "customer_info/customer_info.xlsx",42 "process_infographic": "process_infographic/GF_process_infographic.html"43 },44 "PROCESS_IMAGES_PATH": "process_image", # 공정 이미지 경로 추가45 "PROCESS_ICONS": {46 "Batch": "flask",47 "Forming": "industry",48 "Sizing": "tint",49 "Winding": "sync",50 "Drying": "fire",51 "Packing": "box"52 },53 "PROCESS_COLORS": {54 "Batch": "#4285F4", # 파란색55 "Forming": "#EA4335", # 빨간색56 "Sizing": "#FBBC05", # 노란색57 "Winding": "#34A853", # 녹색58 "Drying": "#FF6D01", # 주황색59 "Packing": "#46BDC6" # 청록색60 },61 "PROCESS_NAME_MAPPING": {62 "Batch": "Batch(배합)",63 "Forming": "섬유화",64 "Sizing": "SIZE",65 "Winding": "Winding",66 "Drying": "Drying",67 "Packing": "포장",68 "인수검사": "인수검사",69 "용융": "용융",70 "Chopping": "Chopping",71 "Pelletizer": "Pelletizer",72 "Water Spray": "Water Spray"73 },74 "STOPWORDS": [75 '인한', '로', '으로', '에', '에서', '의', '이', '가', '을', '를', '은', '는', 76 '와', '과', '이나', '또는', '및', '등', '에서의', '로의', '로써', '로서', '하여', 77 '하는', '하게', '하다', '한', '된', '되는', '되어', '때문에', '때문', '통한', 78 '통해', '의한', '의해', '에의한', '에의해', '불균일로', '편차로', '마모로', '저하로', '발생'79 ]80}81 82# 페이지 설정83def setup_page():84 """페이지 기본 설정"""85 st.set_page_config(86 page_title=CONFIG["APP_TITLE"],87 page_icon=CONFIG["APP_ICON"],88 layout="wide",89 initial_sidebar_state="expanded"90 )91 92 # 경고 메시지 무시93 warnings.filterwarnings('ignore')94 95 # 스타일 적용96 add_sidebar_styles()97 add_main_styles()98 99# 스타일 함수100def add_sidebar_styles():101 """사이드바 스타일 추가 함수 - 간결한 디자인"""102 st.markdown(103 """104 <style>105 /* 간결한 사이드바 기본 스타일 */106 [data-testid="stSidebar"] {107 background-color: #f8f9fa;108 padding: 1rem 0.5rem;109 color: #333;110 }111 112 /* 사이드바 내용 컨테이너 */113 [data-testid="stSidebar"] > div:first-child {114 padding: 1rem 0.5rem;115 }116 117 /* 사이드바 제목 스타일 */118 [data-testid="stSidebar"] [data-testid="stMarkdown"] h1 {119 font-size: 1.3rem;120 color: #2C3E50;121 font-weight: 500;122 margin-bottom: 1rem;123 padding-bottom: 0.5rem;124 border-bottom: 1px solid #e0e0e0;125 }126 127 /* 사이드바 소제목 스타일 */128 [data-testid="stSidebar"] [data-testid="stMarkdown"] h3 {129 font-size: 1rem;130 color: #2C3E50;131 margin-top: 1rem;132 margin-bottom: 0.5rem;133 font-weight: 500;134 }135 136 /* 구분선 스타일 */137 [data-testid="stSidebar"] hr {138 border-color: #e0e0e0;139 margin: 1rem 0;140 }141 142 /* 라디오 버튼 컨테이너 스타일 */143 [data-testid="stSidebar"] [data-testid="stRadio"] > div {144 display: flex;145 flex-direction: column;146 gap: 0.25rem;147 }148 149 /* 라디오 버튼 레이블 스타일 */150 [data-testid="stSidebar"] [data-testid="stRadio"] > div > label {151 border-radius: 4px;152 padding: 0.5rem 0.5rem 0.5rem 1.5rem; /* 왼쪽 패딩 증가 */153 transition: all 0.2s ease;154 border-left: 2px solid transparent;155 }156 157 /* 라디오 버튼 호버 효과 */158 [data-testid="stSidebar"] [data-testid="stRadio"] > div > label:hover {159 background-color: rgba(0, 0, 0, 0.05);160 border-left: 2px solid #2563eb;161 }162 163 /* 선택된 라디오 버튼 스타일 */164 [data-testid="stSidebar"] [data-testid="stRadio"] > div > label[aria-checked="true"] {165 background-color: rgba(37, 99, 235, 0.1);166 border-left: 2px solid #2563eb;167 }168 169 /* 버튼 스타일 */170 [data-testid="stSidebar"] button {171 width: 100%;172 background-color: #f1f3f5;173 color: #333;174 border-radius: 4px;175 padding: 0.5rem;176 font-weight: 500;177 border: 1px solid #dee2e6;178 margin-top: 1rem;179 transition: all 0.2s ease;180 }181 182 /* 버튼 호버 효과 */183 [data-testid="stSidebar"] button:hover {184 background-color: #e9ecef;185 border-color: #ced4da;186 }187 188 /* 로고 컨테이너 스타일 */189 .sidebar-logo-container {190 text-align: center;191 padding: 0.5rem 0;192 margin-bottom: 1rem;193 }194 195 /* 로고 이미지 스타일 */196 .sidebar-logo-container img {197 max-width: 60px;198 height: auto;199 }200 201 /* 사이드바 푸터 스타일 */202 .sidebar-footer {203 margin-top: 1rem;204 padding-top: 0.5rem;205 border-top: 1px solid #e0e0e0;206 font-size: 0.75rem;207 color: #6c757d;208 text-align: center;209 }210 </style>211 """, 212 unsafe_allow_html=True213 )214 215def add_main_styles():216 """메인 컨텐츠 스타일 추가 함수 - 간결한 디자인"""217 st.markdown(218 """219 <style>220 /* 전체 페이지 스타일 */221 .stApp {222 background-color: #ffffff;223 }224 225 /* 메인 컨테이너 스타일 */226 [data-testid="stAppViewContainer"] {227 background-color: #ffffff;228 padding: 0.5rem;229 }230 231 /* 메인 컨텐츠 영역 스타일 */232 .main .block-container {233 padding: 1.5rem;234 max-width: 1200px;235 margin: 0 auto;236 }237 238 /* 타이틀 스타일 */239 h1 {240 color: #2C3E50;241 font-weight: 600;242 font-size: 1.8rem;243 margin-bottom: 2rem;244 padding-bottom: 0.5rem;245 border-bottom: 1px solid #f0f0f0;246 }247 248 /* 서브 타이틀 스타일 */249 h2 {250 color: #2C3E50;251 margin-top: 2rem;252 margin-bottom: 1rem;253 font-weight: 500;254 font-size: 1.4rem;255 }256 257 /* h3 특별 스타일 - 메인 타이틀과의 간격 */258 h3 {259 color: #2C3E50;260 margin-top: 3rem;261 margin-bottom: 1rem;262 font-weight: 500;263 font-size: 1.2rem;264 padding-left: 1rem; /* 왼쪽 여백 증가 */265 border-left: 4px solid #2563EB; /* 왼쪽 테두리 추가 */266 }267 268 /* 위젯 간격 조정 */269 .stSelectbox, .stMultiSelect {270 margin-bottom: 0.75rem;271 }272 273 /* 메트릭 카드 스타일 */274 [data-testid="stMetric"] {275 background-color: #f8f9fa;276 border-radius: 6px;277 padding: 0.75rem !important;278 border-left: 3px solid #2563eb;279 }280 281 /* 메트릭 레이블 스타일 */282 [data-testid="stMetric"] > div:first-child {283 color: #6c757d;284 }285 286 /* 메트릭 값 스타일 */287 [data-testid="stMetric"] > div:nth-child(2) {288 font-size: 1.3rem;289 font-weight: 600;290 color: #2C3E50;291 }292 293 /* 데이터프레임 스타일 */294 .dataframe {295 border-collapse: collapse !important;296 width: 100% !important;297 border-radius: 6px;298 overflow: hidden;299 }300 301 .dataframe th {302 background-color: #f1f3f5 !important;303 color: #495057 !important;304 font-weight: 500 !important;305 padding: 0.5rem 0.75rem !important;306 text-align: left !important;307 }308 309 .dataframe td {310 padding: 0.5rem 0.75rem !important;311 border-top: 1px solid #f0f0f0 !important;312 }313 314 .dataframe tr:nth-child(even) {315 background-color: #f8f9fa !important;316 }317 318 /* 탭 스타일 개선 */319 .stTabs [data-baseweb="tab-list"] {320 gap: 0;321 border-bottom: 1px solid #e0e0e0;322 }323 324 .stTabs [data-baseweb="tab"] {325 height: 40px;326 white-space: pre-wrap;327 background-color: transparent;328 border-radius: 0;329 border-bottom: 2px solid transparent;330 padding: 0 1rem;331 font-weight: 500;332 color: #6c757d;333 transition: all 0.2s ease;334 }335 336 .stTabs [aria-selected="true"] {337 color: #2563eb !important;338 border-bottom: 2px solid #2563eb !important;339 background-color: transparent !important;340 }341 342 /* 이슈 박스 스타일 */343 .issue-box {344 background-color: #f8f9fa;345 border-radius: 6px;346 padding: 1rem;347 margin: 0.75rem 0;348 border-left: 3px solid #2563eb;349 }350 351 /* 공정 박스 스타일 */352 .process-box {353 background-color: #f8f9fa;354 border-radius: 6px;355 padding: 1rem;356 margin: 0.75rem 0;357 border-left: 3px solid #2563eb;358 }359 360 /* 불량 박스 스타일 */361 .defect-box {362 background-color: #f8f9fa;363 border-radius: 6px;364 padding: 1rem;365 margin: 0.75rem 0;366 border-left: 3px solid #2563eb;367 }368 369 /* 서브헤더 스타일 추가 */370 .stMarkdown h3 {371 margin-left: 0.5rem;372 padding-left: 1rem;373 }374 375 /* 추가 마크다운 내 소제목 스타일 */376 .stMarkdown h4 {377 margin-left: 0.75rem;378 }379 </style>380 """, 381 unsafe_allow_html=True382 )383 384# 인증 관련 함수385def check_password():386 """비밀번호 확인 함수"""387 if "password_correct" not in st.session_state:388 st.session_state["password_correct"] = False389 390 if not st.session_state["password_correct"]:391 # 비밀번호 입력 필드를 한 번만 표시392 with st.container():393 password = st.text_input(394 "비밀번호를 입력하세요", 395 type="password",396 key="password_input"397 )398 399 if password:400 if password == CONFIG["PASSWORD"]:401 st.session_state["password_correct"] = True402 # 비밀번호가 맞으면 페이지 리프레시403 st.rerun()404 else:405 st.error("비밀번호가 올바르지 않습니다.")406 return False407 return False408 409 return True410 411# 세션 상태 초기화412def initialize_session_state():413 """세션 상태 초기화 함수"""414 # 기본 세션 상태 변수들 초기화415 default_states = {416 'password_correct': False,417 'selected_product': None,418 'selected_data': None,419 'tab_selection': "공정 현황",420 'data_loaded': False,421 'data_dict': {},422 'last_data_refresh': None423 }424 425 # 세션 상태에 없는 변수들만 초기화426 for key, value in default_states.items():427 if key not in st.session_state:428 st.session_state[key] = value429 430# 데이터 관련 함수431@lru_cache(maxsize=32)432def safe_read_excel(filepath, default_data=None):433 """안전하게 엑셀 파일을 읽는 함수 (캐싱 적용)"""434 try:435 if os.path.exists(filepath):436 logger.info(f"파일 로드 중: {filepath}")437 return pd.read_excel(filepath)438 else:439 logger.warning(f"파일을 찾을 수 없습니다: {filepath}")440 return default_data if default_data is not None else pd.DataFrame()441 except Exception as e:442 logger.error(f"파일 읽기 오류 ({filepath}): {str(e)}")443 return default_data if default_data is not None else pd.DataFrame()444 445def load_data():446 """데이터 로드 함수 (개선된 버전)"""447 # 이미 로드된 데이터가 있고, 마지막 새로고침 시간이 30분 이내면 재사용448 if (st.session_state.data_loaded and 449 st.session_state.last_data_refresh is not None and450 (datetime.now() - st.session_state.last_data_refresh).total_seconds() < 1800):451 logger.info("캐시된 데이터 사용")452 return st.session_state.data_dict453 454 try:455 # 데이터 사전 초기화456 data_dict = {}457 458 # 각 데이터 파일 로드 (안전하게)459 for key, path in CONFIG["DATA_PATHS"].items():460 if key != "process_infographic": # HTML 파일은 별도 처리461 data_dict[key] = safe_read_excel(path)462 463 # 날짜 형식 변환 (품질이슈정보의 날짜 컬럼)464 if not data_dict['quality_issues'].empty and '기준일' in data_dict['quality_issues'].columns:465 data_dict['quality_issues']['기준일'] = pd.to_datetime(data_dict['quality_issues']['기준일'], errors='coerce')466 467 # 데이터 로드 상태 및 데이터 저장468 st.session_state.data_loaded = True469 st.session_state.data_dict = data_dict470 st.session_state.last_data_refresh = datetime.now()471 472 # 성공 메시지 제거 (st.success 라인 삭제)473 logger.info("모든 데이터 로드 완료")474 475 return data_dict476 except Exception as e:477 error_msg = f"데이터 로드 중 오류 발생: {str(e)}"478 logger.error(error_msg)479 st.error(error_msg)480 return {}481 482def load_process_infographic():483 """공정 인포그래픽 HTML 로드 함수"""484 try:485 filepath = CONFIG["DATA_PATHS"]["process_infographic"]486 if os.path.exists(filepath):487 with open(filepath, 'r', encoding='utf-8') as f:488 html_content = f.read()489 return html_content490 else:491 logger.warning(f"인포그래픽 파일을 찾을 수 없습니다: {filepath}")492 # 대체 HTML 콘텐츠 제공493 return """494 <div style="text-align: center; padding: 20px; background-color: #f8f9fa; border-radius: 10px;">495 <h3>인포그래픽을 불러올 수 없습니다</h3>496 <p>파일을 찾을 수 없거나 접근할 수 없습니다.</p>497 </div>498 """499 except Exception as e:500 logger.warning(f"인포그래픽 로드 중 오류 발생: {str(e)}")501 # 오류 발생 시 대체 HTML 콘텐츠 제공502 return """503 <div style="text-align: center; padding: 20px; background-color: #f8f9fa; border-radius: 10px;">504 <h3>인포그래픽 로드 오류</h3>505 <p>파일을 읽는 중 오류가 발생했습니다.</p>506 </div>507 """508 509# 계산 및 분석 함수510def calculate_process_capability(data, ucl, lcl, sigma_level=3):511 """공정능력지수 계산 함수"""512 try:513 if len(data) == 0:514 return {515 'Cp': 0,516 'Cpu': 0,517 'Cpl': 0,518 'Cpk': 0,519 'PPM': 0520 }521 522 mean = data.mean()523 std = data.std()524 525 if std == 0:526 return {527 'Cp': float('inf'),528 'Cpu': float('inf'),529 'Cpl': float('inf'),530 'Cpk': float('inf'),531 'PPM': 0532 }533 534 # 공정능력지수 계산535 cp = (ucl - lcl) / (6 * std) if std != 0 else float('inf')536 cpu = (ucl - mean) / (3 * std) if std != 0 else float('inf')537 cpl = (mean - lcl) / (3 * std) if std != 0 else float('inf')538 cpk = min(cpu, cpl)539 540 # 예상불량률 계산 (ppm 단위)541 z_upper = (ucl - mean) / std if std != 0 else float('inf')542 z_lower = (mean - lcl) / std if std != 0 else float('inf')543 ppm_upper = stats.norm.sf(z_upper) * 1000000544 ppm_lower = stats.norm.sf(z_lower) * 1000000545 total_ppm = ppm_upper + ppm_lower546 547 return {548 'Cp': cp,549 'Cpu': cpu,550 'Cpl': cpl,551 'Cpk': cpk,552 'PPM': total_ppm553 }554 555 except Exception as e:556 logger.error(f"공정능력지수 계산 중 오류 발생: {str(e)}")557 return {558 'Cp': 0,559 'Cpu': 0,560 'Cpl': 0,561 'Cpk': 0,562 'PPM': 0563 }564 565def extract_keywords(text, stopwords=None, min_length=2):566 """텍스트에서 키워드 추출 함수"""567 if stopwords is None:568 stopwords = CONFIG["STOPWORDS"]569 570 # 텍스트를 단어로 분리571 words = text.replace(',', ' ').replace('.', ' ').replace('(', ' ').replace(')', ' ').split()572 573 keywords = {}574 575 for word in words:576 word = word.strip()577 578 # 불용어 제거 및 길이 체크579 if word not in stopwords and len(word) >= min_length:580 # 단어 끝에 붙은 '로', '에', '의' 등 제거581 for suffix in ['로', '에', '의', '을', '를', '은', '는']:582 if word.endswith(suffix) and len(word) > len(suffix):583 word = word[:-len(suffix)]584 585 # 단어가 여전히 최소 길이 이상인지 확인586 if len(word) >= min_length:587 if word in keywords:588 keywords[word] += 1589 else:590 keywords[word] = 1591 592 return keywords593 594# 이미지 관련 유틸리티 함수 추가595def get_image_as_base64(file_path):596 """이미지 파일을 base64로 인코딩하여 반환하는 함수"""597 try:598 if os.path.exists(file_path):599 with open(file_path, "rb") as image_file:600 encoded = base64.b64encode(image_file.read()).decode()601 return f"data:image/png;base64,{encoded}"602 else:603 logger.warning(f"이미지 파일이 존재하지 않습니다: {file_path}")604 return None605 except Exception as e:606 logger.error(f"이미지 인코딩 중 오류 발생: {str(e)}")607 return None608 609# 안전하게 최소값과 최대값을 가져오는 함수 추가610def safe_min_max(values):611 """612 안전하게 값의 최소값과 최대값을 반환하는 함수613 해시 불가능한 타입(예: 집합, 리스트)에 대해서도 작동614 615 Args:616 values: 최소값과 최대값을 구할 값들617 618 Returns:619 tuple: (최소값, 최대값)620 """621 try:622 return values.min(), values.max()623 except (TypeError, AttributeError):624 # 해시 불가능한 타입이거나 min/max 메서드가 없는 경우625 values_list = list(values)626 return min(values_list), max(values_list)627 628# 시각화 함수629def create_process_flow_html(process_stages, process_info_df):630 """공정 흐름도 HTML 생성 함수 - 이미지 처리 개선 버전"""631 # 기본 색상 및 아이콘 설정632 default_color = "#6C757D"633 default_icon = "cog"634 635 # 공정명과 이미지 파일명 매핑 - 모두 소문자로 통일636 process_image_mapping = {637 "batch": "mixing.png",638 "forming": "fiberizing.png",639 "sizing": "sizing.png", 640 "winding": "winding.png",641 "drying": "fbd.png",642 "packing": "packing.png",643 "인수검사": "packing.png",644 "용융": "furnace.png",645 "chopping": "chopping.png",646 "pelletizer": "pelletizer.png",647 "water spray": "water_spray.png", # 공백 대신 언더스코어 사용648 "fbd": "fbd.png",649 # 누락된 공정 이미지 매핑 추가650 "성형": "fiberizing.png", # '성형'은 'forming'과 동일하게 매핑651 "가공": "fbd.png", # 가공 이미지652 "검사/포장": "packing.png", # 검사/포장 이미지653 # 추가 매핑 - 특수문자 및 대소문자 변환 전에 매핑되도록654 "검사": "packing.png"655 }656 657 # 디버깅을 위한 로깅 추가658 logger.info(f"공정 단계 목록: {process_stages}")659 660 # 이미지 파일 디렉토리 확인 로깅661 image_dir = CONFIG['PROCESS_IMAGES_PATH']662 if os.path.exists(image_dir):663 logger.info(f"이미지 디렉토리 존재: {image_dir}")664 # 디렉토리 내 파일 목록 로깅665 files = os.listdir(image_dir)666 logger.info(f"디렉토리 내 파일 목록: {files}")667 else:668 logger.error(f"이미지 디렉토리가 존재하지 않음: {image_dir}")669 670 # HTML로 공정 흐름도 생성671 flow_html = """672 <style>673 /* 공정 흐름도 컨테이너 스타일 */674 .flow-container {675 display: flex;676 flex-wrap: nowrap;677 overflow-x: auto;678 padding: 1rem 0;679 align-items: center;680 max-width: 100%;681 }682 683 /* 각 공정 단계 스타일 */684 .flow-step {685 display: flex;686 flex-direction: row;687 align-items: center;688 flex: 0 0 auto;689 margin-right: 1rem;690 }691 692 /* 화살표 스타일 */693 .arrow {694 margin: 0 1rem;695 color: #6c757d;696 font-size: 1.2rem;697 }698 699 /* 아이콘 컨테이너 스타일 */700 .flow-icon {701 width: 70px;702 height: 70px;703 border-radius: 50%;704 border: 2px solid #4285F4;705 display: flex;706 align-items: center;707 justify-content: center;708 margin-right: 0.5rem;709 background-color: white;710 overflow: hidden;711 cursor: pointer;712 flex-shrink: 0;713 }714 715 /* 아이콘 이미지 스타일 */716 .flow-icon img {717 width: 50px;718 height: 50px;719 object-fit: contain;720 }721 722 /* 공정 아이콘 스타일 */723 .flow-icon i {724 font-size: 2rem;725 }726 727 /* 공정 단계 콘텐츠 스타일 */728 .flow-step-content {729 display: flex;730 flex-direction: column;731 min-width: 100px;732 max-width: 150px;733 }734 735 /* 공정 단계 제목 스타일 */736 .flow-step-title {737 font-weight: bold;738 margin-bottom: 0.25rem;739 color: #333;740 }741 742 /* 공정 단계 설명 스타일 */743 .flow-step-desc {744 font-size: 0.8rem;745 color: #666;746 white-space: nowrap;747 overflow: hidden;748 text-overflow: ellipsis;749 }750 751 /* 마지막 단계 스타일 */752 .flow-step.last-step {753 margin-right: 0;754 }755 756 /* 반응형 스타일 */757 @media (max-width: 768px) {758 .flow-icon {759 width: 50px;760 height: 50px;761 }762 763 .flow-icon img {764 width: 35px;765 height: 35px;766 }767 768 .flow-step-content {769 min-width: 80px;770 }771 }772 </style>773 774 <div class="flow-container">775 """776 777 # 각 공정 단계별 HTML 생성778 for i, stage in enumerate(process_stages):779 # 해당 공정 단계의 세부 공정들 추출780 sub_processes = process_info_df[process_info_df['표준단위공정'] == stage]['실공정(Step)'].tolist()781 sub_process_text = ", ".join(sub_processes)782 783 # 아이콘 및 색상 결정784 icon = CONFIG["PROCESS_ICONS"].get(stage, default_icon)785 color = CONFIG["PROCESS_COLORS"].get(stage, default_color)786 787 # 마지막 단계 여부 확인788 is_last = (i == len(process_stages) - 1)789 last_class = " last-step" if is_last else ""790 791 # 이미지 파일 경로 결정 (매핑 로직 개선)792 image_file = None793 794 # 1. 원본 이름으로 매핑 먼저 시도 (특수문자가 있는 경우도 처리)795 if stage in process_image_mapping:796 image_file = process_image_mapping[stage]797 logger.info(f"원본 이름으로 이미지 매핑: '{stage}' → {image_file}")798 799 # 2. 소문자 변환 후 매핑 시도800 elif stage.lower() in process_image_mapping:801 image_file = process_image_mapping[stage.lower()]802 logger.info(f"소문자 변환으로 이미지 매핑: '{stage}' → {image_file}")803 804 # 3. 특수문자 제거 및 공백 언더스코어 변환 후 매핑 시도805 else:806 # 특수문자를 공백으로 치환하고 연속된 공백을 하나로 합침807 normalized_stage = re.sub(r'[^\w\s]', ' ', stage).lower()808 normalized_stage = re.sub(r'\s+', ' ', normalized_stage).strip()809 810 # 공백을 언더스코어로 변경811 normalized_stage_us = normalized_stage.replace(' ', '_')812 813 if normalized_stage in process_image_mapping:814 image_file = process_image_mapping[normalized_stage]815 logger.info(f"정규화 이름으로 이미지 매핑: '{stage}' → '{normalized_stage}' → {image_file}")816 elif normalized_stage_us in process_image_mapping:817 image_file = process_image_mapping[normalized_stage_us]818 logger.info(f"언더스코어 변환으로 이미지 매핑: '{stage}' → '{normalized_stage_us}' → {image_file}")819 else:820 # 부분 매칭 시도 (예: "검사/포장" → "검사"로 부분 매칭)821 for key in process_image_mapping:822 if key in stage.lower() or stage.lower() in key:823 image_file = process_image_mapping[key]824 logger.info(f"부분 매칭으로 이미지 매핑: '{stage}' 내에서 '{key}' 발견 → {image_file}")825 break826 827 if not image_file:828 logger.warning(f"단계 '{stage}'에 대한 이미지 파일 매핑 없음, 기본 아이콘 사용")829 830 # 이미지 파일 경로 완성 및 존재 여부 확인831 image_path = None832 base64_image = None833 834 if image_file:835 # 절대 경로 사용836 image_path = os.path.join(os.path.abspath(CONFIG['PROCESS_IMAGES_PATH']), image_file)837 logger.info(f"이미지 경로: {image_path}")838 839 if os.path.exists(image_path):840 try:841 base64_image = get_image_as_base64(image_path)842 logger.info(f"이미지 인코딩 성공: {image_path}")843 except Exception as e:844 logger.error(f"이미지 인코딩 오류: {image_path}, 오류: {str(e)}")845 else:846 # 파일이 없을 경우 대체 파일 시도 (확장자 변경)847 alt_extensions = ['.jpg', '.jpeg', '.gif', '.png']848 for ext in alt_extensions:849 alt_path = os.path.splitext(image_path)[0] + ext850 if os.path.exists(alt_path):851 try:852 base64_image = get_image_as_base64(alt_path)853 logger.info(f"대체 이미지 인코딩 성공: {alt_path}")854 break855 except Exception as e:856 logger.error(f"대체 이미지 인코딩 오류: {alt_path}, 오류: {str(e)}")857 858 if not base64_image:859 logger.warning(f"이미지 파일이 존재하지 않음: {image_path}")860 861 # 세부 공정 설명 추출 (첫 번째 세부 공정의 설명 사용)862 process_description = ""863 if sub_processes:864 first_sub_process = process_info_df[865 (process_info_df['표준단위공정'] == stage) & 866 (process_info_df['실공정(Step)'] == sub_processes[0])867 ]868 if not first_sub_process.empty:869 process_description = first_sub_process.iloc[0].get('공정내용', '')870 871 # base64로 인코딩된 이미지가 있는 경우와 없는 경우 처리872 if base64_image:873 # 이미지가 있는 경우 base64 인코딩된 이미지 표시874 icon_content = f"""875 <img src="{base64_image}" alt="{stage}" style="width: 100%; height: 100%; object-fit: contain;">876 """877 else:878 # 이미지가 없는 경우 아이콘 표시879 icon_content = f"""880 <i class="fas fa-{icon}" style="color: {color}; font-size: 2rem;"></i>881 """882 883 # 단계별 HTML 추가884 flow_html += f"""885 <div class="flow-step{last_class}">886 <div class="flow-icon" style="border-color: {color};">887 {icon_content}888 </div>889 <div class="flow-step-content">890 <div class="flow-step-title">{stage}</div>891 <div class="flow-step-desc">{sub_process_text[:30]}{'...' if len(sub_process_text) > 30 else ''}</div>892 </div>893 {'' if is_last else '<div class="arrow"><i class="fas fa-chevron-right"></i></div>'}894 </div>895 """896 897 # FontAwesome 추가898 flow_html += """899 </div>900 901 <!-- FontAwesome 추가 -->902 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">903 """904 905 return flow_html906 907def create_bar_chart(data, x, y, title, color_scale='Viridis', height=400):908 """막대 차트 생성 함수"""909 fig = px.bar(910 data,911 x=x,912 y=y,913 title=title,914 color=y,915 color_continuous_scale=color_scale916 )917 918 fig.update_layout(919 xaxis_title=x,920 yaxis_title=y,921 height=height922 )923 924 return fig925 926def create_pie_chart(data, values, names, title, hole=0.4, color_sequence=None):927 """파이 차트 생성 함수"""928 fig = px.pie(929 data,930 values=values,931 names=names,932 title=title,933 hole=hole,934 color_discrete_sequence=color_sequence935 )936 937 fig.update_traces(textposition='inside', textinfo='percent+label')938 return fig939 940def create_line_chart(x, y, title, line_color='blue', marker_size=8, height=400):941 """선 차트 생성 함수"""942 fig = go.Figure()943 944 fig.add_trace(go.Scatter(945 x=x,946 y=y,947 mode='lines+markers',948 name='데이터',949 line=dict(color=line_color, width=2),950 marker=dict(size=marker_size)951 ))952 953 fig.update_layout(954 title=title,955 xaxis_title="X축",956 yaxis_title="Y축",957 height=height958 )959 960 return fig961 962def create_pareto_chart(data, x, y, title, height=500):963 """파레토 차트 생성 함수"""964 fig = go.Figure()965 966 # 불량량 막대 그래프967 fig.add_trace(go.Bar(968 x=data[x],969 y=data[y],970 name='불량량',971 marker=dict(color='indianred')972 ))973 974 # 누적 불량량 계산975 data['누적비율'] = data[y].cumsum() / data[y].sum() * 100976 977 # 누적 비율 선 그래프978 fig.add_trace(go.Scatter(979 x=data[x],980 y=data['누적비율'],981 name='누적 비율',982 mode='lines+markers',983 yaxis='y2',984 line=dict(color='royalblue', width=2),985 marker=dict(size=8)986 ))987 988 # 80% 기준선989 fig.add_trace(go.Scatter(990 x=[data[x].iloc[0], data[x].iloc[-1]],991 y=[80, 80],992 name='80% 기준',993 mode='lines',994 yaxis='y2',995 line=dict(color='green', dash='dash')996 ))997 998 # 그래프 레이아웃 설정999 fig.update_layout(1000 title=title,1001 xaxis_title=x,1002 yaxis_title=y,1003 yaxis2=dict(1004 title="누적 비율 (%)",1005 overlaying='y',1006 side='right',1007 range=[0, 100]1008 ),1009 legend=dict(1010 orientation="h",1011 yanchor="bottom",1012 y=1.02,1013 xanchor="right",1014 x=11015 ),1016 height=height1017 )1018 1019 return fig1020 1021def create_histogram_with_normal(values, usl, lsl, mean, std, title, height=400):1022 """히스토그램 및 정규분포 생성 함수"""1023 hist_fig = go.Figure()1024 1025 # 히스토그램 추가1026 hist_fig.add_trace(go.Histogram(1027 x=values,1028 name='검사값 분포',1029 opacity=0.7,1030 marker=dict(color='royalblue'),1031 histnorm='probability density'1032 ))1033 1034 try:1035 x_min = values.min() - 0.51036 x_max = values.max() + 0.51037 except TypeError:1038 # values가 해시 불가능한 타입인 경우1039 values_list = list(values)1040 x_min = min(values_list) - 0.51041 x_max = max(values_list) + 0.51042 1043 x_range = np.linspace(x_min, x_max, 100)1044 y_range = stats.norm.pdf(x_range, mean, std)1045 1046 hist_fig.add_trace(go.Scatter(1047 x=x_range,1048 y=y_range,1049 mode='lines',1050 name='정규분포',1051 line=dict(color='red', width=2)1052 ))1053 1054 # 규격 상한/하한 추가1055 hist_fig.add_trace(go.Scatter(1056 x=[usl, usl],1057 y=[0, max(y_range) * 1.2],1058 mode='lines',1059 name='상한 규격',1060 line=dict(color='green', width=2, dash='dash')1061 ))1062 1063 hist_fig.add_trace(go.Scatter(1064 x=[lsl, lsl],1065 y=[0, max(y_range) * 1.2],1066 mode='lines',1067 name='하한 규격',1068 line=dict(color='green', width=2, dash='dash')1069 ))1070 1071 # 평균선 추가1072 hist_fig.add_trace(go.Scatter(1073 x=[mean, mean],1074 y=[0, max(y_range) * 1.2],1075 mode='lines',1076 name='평균',1077 line=dict(color='black', width=2)1078 ))1079 1080 # 그래프 레이아웃 설정1081 hist_fig.update_layout(1082 title=title,1083 xaxis_title="검사값",1084 yaxis_title="확률 밀도",1085 legend=dict(1086 orientation="h",1087 yanchor="bottom",1088 y=1.02,1089 xanchor="right",1090 x=11091 ),1092 height=height1093 )1094 1095 return hist_fig1096 1097# 화면 표시 함수1098def display_quality_analysis(data):1099 """품질 분석 표시 함수"""1100 st.markdown("<div style='margin-top: 30px;'></div>", unsafe_allow_html=True)1101 st.markdown("#### 품질 분석")1102 1103 # 공정능력 정보 확인1104 process_capability = data.get('process_capability', pd.DataFrame())1105 1106 if process_capability.empty:1107 st.warning("공정능력 정보를 불러올 수 없습니다.")1108 return1109 1110 # 제품 및 검사항목 선택을 좌우로 배치1111 if '제품' in process_capability.columns:1112 products = process_capability['제품'].unique()1113 if len(products) > 0:1114 col_product, col_item = st.columns(2)1115 1116 with col_product:1117 selected_product = st.selectbox("제품 선택", products)1118 1119 # 선택된 제품의 공정능력 데이터1120 product_capability = process_capability[process_capability['제품'] == selected_product]1121 1122 # 검사항목 선택1123 if '검사항목' in product_capability.columns and not product_capability.empty:1124 inspection_items = product_capability['검사항목'].unique()1125 if len(inspection_items) > 0:1126 with col_item:1127 selected_item = st.selectbox("검사항목 선택", inspection_items)1128 1129 # 선택된 검사항목의 데이터1130 item_data = product_capability[product_capability['검사항목'] == selected_item]1131 1132 if not item_data.empty:1133 # 월별 공정능력 차트1134 st.markdown("#### 월별 공정능력 추이")1135 1136 # 필요한 컬럼이 있는지 확인1137 required_columns = ['월', 'Cpk']1138 1139 if '월' in item_data.columns:1140 fig = go.Figure()1141 1142 # 검사값과 편차를 사용한 그래프 (우선순위)1143 if '검사값' in item_data.columns and '편차' in item_data.columns:1144 # 검사값 그래프1145 fig.add_trace(go.Scatter(1146 x=item_data['월'],1147 y=item_data['검사값'],1148 mode='lines+markers',1149 name='검사값',1150 line=dict(color='#2563eb', width=2),1151 marker=dict(size=8)1152 ))1153 1154 # 상한선 (검사값 + 편차)1155 fig.add_trace(go.Scatter(1156 x=item_data['월'],1157 y=item_data['검사값'] + item_data['편차'],1158 mode='lines',1159 name='검사값 + 편차',1160 line=dict(color='#93c5fd', width=1, dash='dot'),1161 fill=None1162 ))1163 1164 # 하한선 (검사값 - 편차)1165 fig.add_trace(go.Scatter(1166 x=item_data['월'],1167 y=item_data['검사값'] - item_data['편차'],1168 mode='lines',1169 name='검사값 - 편차',1170 line=dict(color='#93c5fd', width=1, dash='dot'),1171 fill='tonexty', # 상한선과 하한선 사이 영역 채우기1172 fillcolor='rgba(37, 99, 235, 0.1)'1173 ))1174 1175 # 그래프 제목 설정1176 chart_title = f"{selected_product} - {selected_item} 월별 검사값 및 편차 추이"1177 y_axis_title = "측정값"1178 1179 # 평균값과 표준편차 그래프 (두 번째 우선순위)1180 elif '평균' in item_data.columns and '표준편차' in item_data.columns:1181 # 평균값 그래프1182 fig.add_trace(go.Scatter(1183 x=item_data['월'],1184 y=item_data['평균'],1185 mode='lines+markers',1186 name='평균값',1187 line=dict(color='#2563eb', width=2),1188 marker=dict(size=8)1189 ))1190 1191 # 상한선 (평균 + 표준편차)1192 fig.add_trace(go.Scatter(1193 x=item_data['월'],1194 y=item_data['평균'] + item_data['표준편차'],1195 mode='lines',1196 name='평균 + 1σ',1197 line=dict(color='#93c5fd', width=1, dash='dot'),1198 fill=None1199 ))1200 