407 lines
16 KiB
Python
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()
|