592 lines
21 KiB
Python
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()
|