""" solar_converter_gui.py - 태양광 발전 엑셀 데이터 전처리 GUI ============================================================ 엑셀 데이터를 읽어 DB 업로드용 표준 형식으로 변환합니다. """ import tkinter as tk from tkinter import ttk, filedialog, messagebox import json import os from pathlib import Path try: import pandas as pd except ImportError: print("pandas 설치 필요: pip install pandas openpyxl") exit(1) class SolarConverterApp: """태양광 발전 엑셀 변환 GUI 애플리케이션""" CONFIG_FILE = "mapping.json" def __init__(self, root): self.root = root self.root.title("☀️ 태양광 발전 데이터 변환기") self.root.geometry("900x700") self.root.minsize(800, 600) # 상태 변수 self.file_path = tk.StringVar(value="파일을 선택하세요") self.skip_rows = tk.IntVar(value=0) self.df = None # 원본 데이터프레임 self.columns = [] # 컬럼 리스트 self.mapping_widgets = {} # 컬럼별 매핑 위젯 self._create_ui() self._load_mapping() def _create_ui(self): """UI 생성""" # 메인 프레임 main_frame = ttk.Frame(self.root, padding=10) main_frame.pack(fill=tk.BOTH, expand=True) # === 1. 파일 선택 영역 === file_frame = ttk.LabelFrame(main_frame, text="📁 파일 선택", padding=10) file_frame.pack(fill=tk.X, pady=(0, 10)) ttk.Button(file_frame, text="원본 엑셀 열기", command=self._open_file).pack(side=tk.LEFT) ttk.Label(file_frame, textvariable=self.file_path, foreground="blue").pack(side=tk.LEFT, padx=10) # === 2. 구조 분석 영역 === analysis_frame = ttk.LabelFrame(main_frame, text="🔍 구조 분석", padding=10) analysis_frame.pack(fill=tk.X, pady=(0, 10)) row1 = ttk.Frame(analysis_frame) row1.pack(fill=tk.X) ttk.Label(row1, text="건너뛸 행(Skip Rows):").pack(side=tk.LEFT) ttk.Spinbox(row1, from_=0, to=100, width=5, textvariable=self.skip_rows).pack(side=tk.LEFT, padx=5) ttk.Button(row1, text="📋 미리보기", command=self._preview_data).pack(side=tk.LEFT, padx=10) # 미리보기 테이블 preview_container = ttk.Frame(analysis_frame) preview_container.pack(fill=tk.BOTH, expand=True, pady=(10, 0)) self.preview_tree = ttk.Treeview(preview_container, height=5, show="headings") preview_scroll_x = ttk.Scrollbar(preview_container, orient=tk.HORIZONTAL, command=self.preview_tree.xview) preview_scroll_y = ttk.Scrollbar(preview_container, orient=tk.VERTICAL, command=self.preview_tree.yview) self.preview_tree.configure(xscrollcommand=preview_scroll_x.set, yscrollcommand=preview_scroll_y.set) self.preview_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) preview_scroll_y.pack(side=tk.RIGHT, fill=tk.Y) preview_scroll_x.pack(side=tk.BOTTOM, fill=tk.X) # === 3. 발전소 매핑 영역 === mapping_outer = ttk.LabelFrame(main_frame, text="🔗 발전소 매핑", padding=10) mapping_outer.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) # 매핑 버튼 행 mapping_buttons = ttk.Frame(mapping_outer) mapping_buttons.pack(fill=tk.X, pady=(0, 10)) ttk.Button(mapping_buttons, text="💾 매핑 저장", command=self._save_mapping).pack(side=tk.LEFT, padx=2) ttk.Button(mapping_buttons, text="📂 매핑 불러오기", command=self._load_mapping).pack(side=tk.LEFT, padx=2) ttk.Button(mapping_buttons, text="🗑️ 매핑 초기화", command=self._clear_mapping).pack(side=tk.LEFT, padx=2) ttk.Label(mapping_buttons, text=" | 날짜 컬럼:").pack(side=tk.LEFT, padx=(20, 5)) self.date_column_var = tk.StringVar() self.date_column_combo = ttk.Combobox(mapping_buttons, textvariable=self.date_column_var, width=15, state="readonly") self.date_column_combo.pack(side=tk.LEFT) # 매핑 스크롤 영역 mapping_canvas = tk.Canvas(mapping_outer) mapping_scrollbar = ttk.Scrollbar(mapping_outer, orient=tk.VERTICAL, command=mapping_canvas.yview) self.mapping_frame = ttk.Frame(mapping_canvas) mapping_canvas.configure(yscrollcommand=mapping_scrollbar.set) mapping_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) mapping_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) canvas_window = mapping_canvas.create_window((0, 0), window=self.mapping_frame, anchor="nw") def on_frame_configure(e): mapping_canvas.configure(scrollregion=mapping_canvas.bbox("all")) def on_canvas_configure(e): mapping_canvas.itemconfig(canvas_window, width=e.width) self.mapping_frame.bind("", on_frame_configure) mapping_canvas.bind("", on_canvas_configure) # 마우스 휠 스크롤 def on_mousewheel(e): mapping_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") mapping_canvas.bind_all("", on_mousewheel) # === 4. 변환 및 저장 영역 === action_frame = ttk.Frame(main_frame) action_frame.pack(fill=tk.X) ttk.Button( action_frame, text="⬇️ 변환하여 저장하기", command=self._convert_and_save, style="Accent.TButton" ).pack(side=tk.RIGHT) self.status_label = ttk.Label(action_frame, text="준비됨", foreground="gray") self.status_label.pack(side=tk.LEFT) # 스타일 설정 style = ttk.Style() style.configure("Accent.TButton", font=("", 12, "bold")) def _open_file(self): """파일 열기 대화상자""" path = filedialog.askopenfilename( title="엑셀 파일 선택", filetypes=[ ("Excel Files", "*.xlsx *.xls"), ("All Files", "*.*") ] ) if path: self.file_path.set(path) self._status("파일 선택됨. '미리보기' 버튼을 클릭하세요.") def _preview_data(self): """데이터 미리보기""" path = self.file_path.get() if not os.path.exists(path): messagebox.showerror("오류", "먼저 엑셀 파일을 선택하세요.") return try: skip = self.skip_rows.get() self.df = pd.read_excel(path, skiprows=skip) self.columns = list(self.df.columns) # 미리보기 테이블 업데이트 self.preview_tree.delete(*self.preview_tree.get_children()) self.preview_tree["columns"] = self.columns for col in self.columns: self.preview_tree.heading(col, text=col) self.preview_tree.column(col, width=100, minwidth=50) # 상위 5행 표시 for idx, row in self.df.head(5).iterrows(): values = [str(v) if pd.notna(v) else "" for v in row] self.preview_tree.insert("", tk.END, values=values) # 매핑 UI 생성 self._create_mapping_ui() self._status(f"미리보기 완료: {len(self.df)}행, {len(self.columns)}열") except Exception as e: messagebox.showerror("오류", f"파일 읽기 실패:\n{e}") def _create_mapping_ui(self): """컬럼 매핑 UI 생성""" # 기존 위젯 제거 for widget in self.mapping_frame.winfo_children(): widget.destroy() self.mapping_widgets = {} # 날짜 컬럼 콤보박스 업데이트 self.date_column_combo["values"] = self.columns if self.columns: # 날짜 관련 컬럼 자동 선택 for col in self.columns: if any(kw in str(col).lower() for kw in ['date', '날짜', '일자', 'day']): self.date_column_var.set(col) break else: self.date_column_var.set(self.columns[0]) # 헤더 header = ttk.Frame(self.mapping_frame) header.pack(fill=tk.X, pady=(0, 5)) ttk.Label(header, text="포함", width=5).pack(side=tk.LEFT) ttk.Label(header, text="엑셀 컬럼명", width=25, anchor="w").pack(side=tk.LEFT, padx=5) ttk.Label(header, text="→", width=3).pack(side=tk.LEFT) ttk.Label(header, text="DB Plant ID", width=20, anchor="w").pack(side=tk.LEFT, padx=5) ttk.Separator(self.mapping_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) # 각 컬럼에 대한 매핑 행 for col in self.columns: row = ttk.Frame(self.mapping_frame) row.pack(fill=tk.X, pady=2) # 포함 체크박스 include_var = tk.BooleanVar(value=True) chk = ttk.Checkbutton(row, variable=include_var, width=3) chk.pack(side=tk.LEFT) # 컬럼명 ttk.Label(row, text=str(col), width=25, anchor="w").pack(side=tk.LEFT, padx=5) # 화살표 ttk.Label(row, text="→", width=3).pack(side=tk.LEFT) # DB ID 입력 db_id_var = tk.StringVar() entry = ttk.Entry(row, textvariable=db_id_var, width=20) entry.pack(side=tk.LEFT, padx=5) # 자동 매핑 추천 suggested_id = self._suggest_plant_id(col) if suggested_id: db_id_var.set(suggested_id) # 위젯 저장 self.mapping_widgets[col] = { 'include': include_var, 'db_id': db_id_var } def _suggest_plant_id(self, column_name): """컬럼명에서 발전소 ID 추천""" col_str = str(column_name).lower() # 호기 번호 추출 import re match = re.search(r'(\d+)\s*호기?', col_str) if match: num = match.group(1).zfill(2) return f"nrems-{num}" # 날짜 관련 컬럼은 비워둠 if any(kw in col_str for kw in ['date', '날짜', '일자', 'day', '월', 'month']): return "" return "" def _save_mapping(self): """매핑 설정 저장""" mapping = {} for col, widgets in self.mapping_widgets.items(): mapping[str(col)] = { 'include': widgets['include'].get(), 'db_id': widgets['db_id'].get() } mapping['_date_column'] = self.date_column_var.get() mapping['_skip_rows'] = self.skip_rows.get() try: with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f: json.dump(mapping, f, ensure_ascii=False, indent=2) self._status(f"매핑 저장됨: {self.CONFIG_FILE}") except Exception as e: messagebox.showerror("오류", f"저장 실패: {e}") def _load_mapping(self): """저장된 매핑 불러오기""" if not os.path.exists(self.CONFIG_FILE): return try: with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f: mapping = json.load(f) # skip_rows 복원 if '_skip_rows' in mapping: self.skip_rows.set(mapping['_skip_rows']) # 날짜 컬럼 복원 if '_date_column' in mapping: self.date_column_var.set(mapping['_date_column']) # 매핑 위젯 업데이트 for col, widgets in self.mapping_widgets.items(): col_str = str(col) if col_str in mapping: widgets['include'].set(mapping[col_str].get('include', True)) widgets['db_id'].set(mapping[col_str].get('db_id', '')) self._status("매핑 불러옴") except Exception as e: print(f"매핑 로드 오류: {e}") def _clear_mapping(self): """매핑 초기화""" for widgets in self.mapping_widgets.values(): widgets['include'].set(True) widgets['db_id'].set("") self._status("매핑 초기화됨") 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: messagebox.showerror("오류", "날짜 컬럼을 선택하세요.") return # 매핑된 컬럼 추출 mapped_columns = {} for col, widgets in self.mapping_widgets.items(): if widgets['include'].get() and widgets['db_id'].get(): mapped_columns[col] = widgets['db_id'].get() if not mapped_columns: messagebox.showerror("오류", "최소 하나의 컬럼을 매핑하세요.") return try: # 필요한 컬럼만 추출 cols_to_use = [date_col] + list(mapped_columns.keys()) df_subset = self.df[cols_to_use].copy() # melt로 변환 (wide → long) 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(mapped_columns) # 날짜 표준화 df_melted['date'] = pd.to_datetime(df_melted[date_col]).dt.strftime('%Y-%m-%d') # 최종 결과 result = df_melted[['date', 'plant_id', 'generation']].copy() result['generation'] = pd.to_numeric(result['generation'], errors='coerce').fillna(0) # 저장 위치 선택 save_path = filedialog.asksaveasfilename( title="변환 결과 저장", defaultextension=".xlsx", initialfile="processed_data.xlsx", filetypes=[("Excel Files", "*.xlsx")] ) if not save_path: return # 발전소별로 시트 분리하여 저장 with pd.ExcelWriter(save_path, engine='openpyxl') as writer: # 전체 데이터 시트 result.to_excel(writer, sheet_name='전체', index=False) # plant_id별 시트 for plant_id in result['plant_id'].unique(): plant_data = result[result['plant_id'] == plant_id] # 시트명에 사용 불가한 문자 제거 sheet_name = plant_id.replace('/', '_').replace('\\', '_')[:31] plant_data.to_excel(writer, sheet_name=sheet_name, index=False) messagebox.showinfo( "성공", f"변환 완료!\n\n" f"📊 총 {len(result)}행\n" f"🏭 발전소 {len(result['plant_id'].unique())}개\n" f"📁 저장 위치: {save_path}" ) self._status(f"저장됨: {save_path}") except Exception as e: messagebox.showerror("오류", f"변환 실패:\n{e}") import traceback traceback.print_exc() def _status(self, message): """상태 메시지 업데이트""" self.status_label.config(text=message) self.root.update_idletasks() def main(): root = tk.Tk() app = SolarConverterApp(root) root.mainloop() if __name__ == "__main__": main()