solorpower/tools/solar_gui_modern.py

592 lines
21 KiB
Python

"""
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("<Double-1>", 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()