solorpower/tools/solar_converter_gui.py

407 lines
16 KiB
Python

"""
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("<Configure>", on_frame_configure)
mapping_canvas.bind("<Configure>", on_canvas_configure)
# 마우스 휠 스크롤
def on_mousewheel(e):
mapping_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")
mapping_canvas.bind_all("<MouseWheel>", 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()