""" solar_gui_modern.py - 태양광 데이터 전처리기 (Modern UI) ========================================================== customtkinter를 사용한 현대적인 디자인의 데이터 변환 도구 Requirements: pip install customtkinter pandas openpyxl """ import customtkinter as ctk from tkinter import ttk, filedialog, messagebox import tkinter as tk import pandas as pd import json import os from pathlib import Path # 다크 모드 기본 설정 ctk.set_appearance_mode("Dark") ctk.set_default_color_theme("blue") class SolarApp(ctk.CTk): """태양광 데이터 전처리기 메인 애플리케이션""" CONFIG_FILE = "mapping.json" def __init__(self): super().__init__() # 윈도우 설정 self.title("☀️ Solar Data Preprocessor") self.geometry("1200x800") self.minsize(1000, 700) # 상태 변수 self.file_path = None self.raw_df = None # 원본 데이터 (헤더 없이) self.df = None # 처리된 데이터프레임 self.header_row = 0 # 헤더로 지정된 행 인덱스 self.columns = [] self.mapping_widgets = {} self.saved_mappings = self._load_saved_mappings() self._create_ui() def _create_ui(self): """UI 생성""" # 메인 컨테이너 self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) # === 1. 상단: 파일 로드 카드 === self._create_file_card() # === 2. 중단: 미리보기 + 매핑 === self._create_main_content() # === 3. 하단: 액션 버튼 === self._create_action_bar() def _create_file_card(self): """파일 로드 카드 UI""" card = ctk.CTkFrame(self, corner_radius=15) card.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") card.grid_columnconfigure(1, weight=1) # 파일 열기 버튼 self.open_btn = ctk.CTkButton( card, text="📂 엑셀 파일 열기", font=ctk.CTkFont(size=16, weight="bold"), height=50, width=200, command=self._open_file ) self.open_btn.grid(row=0, column=0, padx=20, pady=20) # 파일 정보 영역 info_frame = ctk.CTkFrame(card, fg_color="transparent") info_frame.grid(row=0, column=1, padx=20, pady=20, sticky="ew") self.file_name_label = ctk.CTkLabel( info_frame, text="파일을 선택하세요", font=ctk.CTkFont(size=14), text_color="gray" ) self.file_name_label.pack(anchor="w") self.file_path_label = ctk.CTkLabel( info_frame, text="", font=ctk.CTkFont(size=11), text_color="gray" ) self.file_path_label.pack(anchor="w") # 상태 라벨 self.status_label = ctk.CTkLabel( card, text="", font=ctk.CTkFont(size=14, weight="bold"), text_color="green" ) self.status_label.grid(row=0, column=2, padx=20, pady=20) def _create_main_content(self): """메인 콘텐츠 (미리보기 + 매핑)""" content = ctk.CTkFrame(self, fg_color="transparent") content.grid(row=1, column=0, padx=20, pady=10, sticky="nsew") content.grid_columnconfigure(0, weight=3) content.grid_columnconfigure(1, weight=2) content.grid_rowconfigure(0, weight=1) # === 좌측: 데이터 미리보기 === self._create_preview_panel(content) # === 우측: 매핑 패널 === self._create_mapping_panel(content) def _create_preview_panel(self, parent): """데이터 미리보기 패널""" panel = ctk.CTkFrame(parent, corner_radius=15) panel.grid(row=0, column=0, padx=(0, 10), sticky="nsew") panel.grid_rowconfigure(1, weight=1) panel.grid_columnconfigure(0, weight=1) # 헤더 header = ctk.CTkFrame(panel, fg_color="transparent") header.grid(row=0, column=0, padx=15, pady=(15, 5), sticky="ew") ctk.CTkLabel( header, text="📊 데이터 미리보기", font=ctk.CTkFont(size=16, weight="bold") ).pack(side="left") self.header_info_label = ctk.CTkLabel( header, text="💡 행을 더블클릭하면 헤더로 지정됩니다", font=ctk.CTkFont(size=12), text_color="gray" ) self.header_info_label.pack(side="right") # Treeview 스타일 (다크 모드에 맞춤) style = ttk.Style() style.theme_use("clam") style.configure("Dark.Treeview", background="#2b2b2b", foreground="white", fieldbackground="#2b2b2b", rowheight=25 ) style.configure("Dark.Treeview.Heading", background="#3d3d3d", foreground="white", font=("", 10, "bold") ) style.map("Dark.Treeview", background=[("selected", "#1f538d")], foreground=[("selected", "white")] ) # Treeview tree_frame = ctk.CTkFrame(panel, fg_color="transparent") tree_frame.grid(row=1, column=0, padx=15, pady=(5, 15), sticky="nsew") tree_frame.grid_rowconfigure(0, weight=1) tree_frame.grid_columnconfigure(0, weight=1) self.tree = ttk.Treeview(tree_frame, style="Dark.Treeview", show="headings") # 스크롤바 scroll_y = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) scroll_x = ttk.Scrollbar(tree_frame, orient="horizontal", command=self.tree.xview) self.tree.configure(xscrollcommand=scroll_x.set, yscrollcommand=scroll_y.set) self.tree.grid(row=0, column=0, sticky="nsew") scroll_y.grid(row=0, column=1, sticky="ns") scroll_x.grid(row=1, column=0, sticky="ew") # 더블클릭 이벤트 self.tree.bind("", self._on_row_double_click) def _create_mapping_panel(self, parent): """매핑 패널""" panel = ctk.CTkFrame(parent, corner_radius=15) panel.grid(row=0, column=1, padx=(10, 0), sticky="nsew") panel.grid_rowconfigure(1, weight=1) panel.grid_columnconfigure(0, weight=1) # 헤더 header = ctk.CTkFrame(panel, fg_color="transparent") header.grid(row=0, column=0, padx=15, pady=(15, 5), sticky="ew") header.grid_columnconfigure(0, weight=1) ctk.CTkLabel( header, text="🔗 발전소 ID 매핑", font=ctk.CTkFont(size=16, weight="bold") ).grid(row=0, column=0, sticky="w") # 날짜 컬럼 선택 date_frame = ctk.CTkFrame(header, fg_color="transparent") date_frame.grid(row=1, column=0, pady=(10, 0), sticky="ew") ctk.CTkLabel( date_frame, text="📅 날짜 컬럼:", font=ctk.CTkFont(size=13) ).pack(side="left") self.date_column_var = ctk.StringVar() self.date_combo = ctk.CTkComboBox( date_frame, variable=self.date_column_var, width=150, state="readonly" ) self.date_combo.pack(side="left", padx=(10, 0)) # 스크롤 가능한 매핑 프레임 self.mapping_scroll = ctk.CTkScrollableFrame(panel, corner_radius=10) self.mapping_scroll.grid(row=1, column=0, padx=15, pady=(10, 15), sticky="nsew") self.mapping_scroll.grid_columnconfigure(1, weight=1) # 초기 안내 메시지 self.mapping_placeholder = ctk.CTkLabel( self.mapping_scroll, text="파일을 로드하고 헤더 행을\n더블클릭하세요", font=ctk.CTkFont(size=14), text_color="gray" ) self.mapping_placeholder.grid(row=0, column=0, columnspan=2, pady=50) def _create_action_bar(self): """하단 액션 바""" bar = ctk.CTkFrame(self, corner_radius=15) bar.grid(row=2, column=0, padx=20, pady=(10, 20), sticky="ew") bar.grid_columnconfigure(0, weight=1) # 진행률 바 self.progress = ctk.CTkProgressBar(bar, width=400) self.progress.grid(row=0, column=0, padx=20, pady=15, sticky="w") self.progress.set(0) self.progress_label = ctk.CTkLabel( bar, text="준비됨", font=ctk.CTkFont(size=12), text_color="gray" ) self.progress_label.grid(row=0, column=1, padx=10, pady=15) # 변환 버튼 self.convert_btn = ctk.CTkButton( bar, text="🚀 변환 및 엑셀 생성", font=ctk.CTkFont(size=16, weight="bold"), height=45, width=200, fg_color="#28a745", hover_color="#218838", command=self._convert_and_save ) self.convert_btn.grid(row=0, column=2, padx=20, pady=15) def _open_file(self): """파일 열기""" path = filedialog.askopenfilename( title="엑셀 파일 선택", filetypes=[("Excel Files", "*.xlsx *.xls"), ("All Files", "*.*")] ) if not path: return self.file_path = path file_name = os.path.basename(path) self.file_name_label.configure(text=f"📄 {file_name}", text_color="white") self.file_path_label.configure(text=path) try: # 헤더 없이 원본 데이터 로드 (상위 30행) self.raw_df = pd.read_excel(path, header=None, nrows=30) self._update_preview() self.status_label.configure(text="✅ 파일 로드 완료", text_color="green") self.header_info_label.configure( text="💡 헤더로 사용할 행을 더블클릭하세요", text_color="#ffc107" ) except Exception as e: self.status_label.configure(text=f"❌ 로드 실패", text_color="red") messagebox.showerror("오류", f"파일 읽기 실패:\n{e}") def _update_preview(self): """미리보기 테이블 업데이트""" if self.raw_df is None: return # 기존 데이터 삭제 self.tree.delete(*self.tree.get_children()) # 컬럼 설정 (인덱스 포함) cols = ["#"] + [f"Col {i+1}" for i in range(len(self.raw_df.columns))] self.tree["columns"] = cols for col in cols: self.tree.heading(col, text=col) self.tree.column(col, width=100 if col != "#" else 50, minwidth=50) # 데이터 삽입 for idx, row in self.raw_df.iterrows(): values = [idx] + [str(v) if pd.notna(v) else "" for v in row] tag = "header" if idx == self.header_row else "" self.tree.insert("", "end", iid=str(idx), values=values, tags=(tag,)) # 헤더 행 강조 self.tree.tag_configure("header", background="#1f538d", foreground="white") def _on_row_double_click(self, event): """행 더블클릭 시 헤더로 지정""" # 클릭한 위치에서 행 식별 item = self.tree.identify_row(event.y) if not item: return try: self.header_row = int(item) except ValueError: return self._update_preview() self.header_info_label.configure( text=f"✅ 헤더 지정됨: {self.header_row}번째 행", text_color="green" ) # 실제 데이터프레임 생성 try: self.df = pd.read_excel( self.file_path, skiprows=self.header_row, header=0 ) self.columns = [str(c) for c in self.df.columns] self._update_mapping_ui() except Exception as e: messagebox.showerror("오류", f"데이터 파싱 실패:\n{e}") def _update_mapping_ui(self): """매핑 UI 업데이트""" # 기존 위젯 제거 for widget in self.mapping_scroll.winfo_children(): widget.destroy() self.mapping_widgets = {} # 날짜 컬럼 콤보박스 업데이트 self.date_combo.configure(values=self.columns) for col in self.columns: if any(kw in col.lower() for kw in ['date', '날짜', '일자']): self.date_column_var.set(col) break else: if self.columns: self.date_column_var.set(self.columns[0]) # 헤더 ctk.CTkLabel( self.mapping_scroll, text="컬럼명", font=ctk.CTkFont(size=12, weight="bold"), text_color="gray" ).grid(row=0, column=0, padx=5, pady=(0, 10), sticky="w") ctk.CTkLabel( self.mapping_scroll, text="Plant ID", font=ctk.CTkFont(size=12, weight="bold"), text_color="gray" ).grid(row=0, column=1, padx=5, pady=(0, 10), sticky="w") ctk.CTkLabel( self.mapping_scroll, text="제외", font=ctk.CTkFont(size=12, weight="bold"), text_color="gray" ).grid(row=0, column=2, padx=5, pady=(0, 10)) # 각 컬럼 매핑 행 for i, col in enumerate(self.columns): row_idx = i + 1 # 컬럼명 ctk.CTkLabel( self.mapping_scroll, text=col[:30] + "..." if len(col) > 30 else col, font=ctk.CTkFont(size=13) ).grid(row=row_idx, column=0, padx=5, pady=5, sticky="w") # Plant ID 입력 id_var = ctk.StringVar() id_entry = ctk.CTkEntry( self.mapping_scroll, textvariable=id_var, width=120, placeholder_text="nrems-01" ) id_entry.grid(row=row_idx, column=1, padx=5, pady=5, sticky="ew") # 제외 체크박스 exclude_var = ctk.BooleanVar(value=False) exclude_chk = ctk.CTkCheckBox( self.mapping_scroll, text="", variable=exclude_var, width=30 ) exclude_chk.grid(row=row_idx, column=2, padx=5, pady=5) # 자동 매핑 적용 suggested = self._get_saved_mapping(col) if suggested: id_var.set(suggested) else: # 호기 번호 추출 시도 import re match = re.search(r'(\d+)\s*호기?', col) if match: num = match.group(1).zfill(2) id_var.set(f"nrems-{num}") elif any(kw in col.lower() for kw in ['date', '날짜', '일자']): exclude_var.set(True) self.mapping_widgets[col] = { 'id_var': id_var, 'exclude_var': exclude_var } def _get_saved_mapping(self, column_name): """저장된 매핑에서 검색""" return self.saved_mappings.get(column_name, "") def _load_saved_mappings(self): """저장된 매핑 로드""" if os.path.exists(self.CONFIG_FILE): try: with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f: return json.load(f) except: pass return {} def _save_mappings(self): """현재 매핑 저장""" mappings = {} for col, widgets in self.mapping_widgets.items(): plant_id = widgets['id_var'].get().strip() if plant_id: mappings[col] = plant_id try: with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f: json.dump(mappings, f, ensure_ascii=False, indent=2) except: pass def _convert_and_save(self): """변환 및 저장""" if self.df is None or self.df.empty: messagebox.showerror("오류", "먼저 파일을 로드하고 헤더를 지정하세요.") return date_col = self.date_column_var.get() if not date_col or date_col not in self.df.columns: messagebox.showerror("오류", "날짜 컬럼을 선택하세요.") return # 매핑 수집 mappings = {} for col, widgets in self.mapping_widgets.items(): if not widgets['exclude_var'].get(): plant_id = widgets['id_var'].get().strip() if plant_id: mappings[col] = plant_id if not mappings: messagebox.showerror("오류", "최소 하나의 컬럼을 매핑하세요.") return # 매핑 저장 self._save_mappings() try: self.progress.set(0.1) self.progress_label.configure(text="데이터 추출 중...") self.update() # 필요한 컬럼 추출 cols_to_use = [date_col] + list(mappings.keys()) df_subset = self.df[cols_to_use].copy() self.progress.set(0.3) self.progress_label.configure(text="구조 변환 중...") self.update() # melt 변환 df_melted = df_subset.melt( id_vars=[date_col], var_name='excel_column', value_name='generation' ) # plant_id 매핑 df_melted['plant_id'] = df_melted['excel_column'].map(mappings) # 날짜 표준화 df_melted['date'] = pd.to_datetime(df_melted[date_col], errors='coerce').dt.strftime('%Y-%m-%d') # 결과 정리 result = df_melted[['date', 'plant_id', 'generation']].copy() result['generation'] = pd.to_numeric(result['generation'], errors='coerce').fillna(0) result = result.dropna(subset=['date']) self.progress.set(0.5) self.progress_label.configure(text="파일 저장 중...") self.update() # 저장 경로 original_name = Path(self.file_path).stem save_path = filedialog.asksaveasfilename( title="변환 결과 저장", defaultextension=".xlsx", initialfile=f"processed_{original_name}.xlsx", filetypes=[("Excel Files", "*.xlsx")] ) if not save_path: self.progress.set(0) self.progress_label.configure(text="취소됨") return # 엑셀 저장 (시트별 분리) plant_ids = result['plant_id'].unique() total_plants = len(plant_ids) with pd.ExcelWriter(save_path, engine='openpyxl') as writer: # 전체 시트 result.to_excel(writer, sheet_name='전체', index=False) # 발전소별 시트 for i, plant_id in enumerate(plant_ids): plant_data = result[result['plant_id'] == plant_id] sheet_name = str(plant_id).replace('/', '_')[:31] plant_data.to_excel(writer, sheet_name=sheet_name, index=False) progress = 0.5 + (0.5 * (i + 1) / total_plants) self.progress.set(progress) self.update() self.progress.set(1.0) self.progress_label.configure(text="완료!", text_color="green") messagebox.showinfo( "변환 완료! 🎉", f"📊 총 {len(result):,}행 변환\n" f"🏭 발전소 {total_plants}개\n" f"📁 저장: {save_path}" ) except Exception as e: self.progress.set(0) self.progress_label.configure(text="오류 발생", text_color="red") messagebox.showerror("오류", f"변환 실패:\n{e}") import traceback traceback.print_exc() def main(): app = SolarApp() app.mainloop() if __name__ == "__main__": main()