solorpower/tools/solar_converter_v2.py

326 lines
14 KiB
Python

import customtkinter as ctk
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pandas as pd
from pandas.tseries.offsets import MonthEnd
import json
import os
import sys
# 폰트 설정
if sys.platform.startswith('win'):
FONT_BOLD = ("Malgun Gothic", 14, "bold")
FONT_NORMAL = ("Malgun Gothic", 12)
FONT_SMALL = ("Malgun Gothic", 10)
else:
FONT_BOLD = ("AppleGothic", 14, "bold")
FONT_NORMAL = ("AppleGothic", 12)
FONT_SMALL = ("AppleGothic", 10)
ctk.set_appearance_mode("Dark")
ctk.set_default_color_theme("blue")
class SolarApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("태양광 데이터 전처리기 V7.0 (즉시 표시 버전)")
self.geometry("1200x900")
self.df_raw = None
self.header_row_idx = 0 # 기본값 0으로 설정
self.file_path = ""
self.mapping_file = "mapping.json"
self.mapping_entries = {}
self.saved_mapping = self.load_mapping()
self.create_ui()
def create_ui(self):
# 1. 상단: 파일 로드
self.frame_top = ctk.CTkFrame(self)
self.frame_top.pack(pady=10, padx=10, fill="x")
self.btn_load = ctk.CTkButton(self.frame_top, text="📂 1. 엑셀 파일 열기",
command=self.load_file, font=FONT_BOLD, height=40)
self.btn_load.pack(side="left", padx=20, pady=10)
self.lbl_file_info = ctk.CTkLabel(self.frame_top, text="파일을 선택해주세요",
text_color="gray", font=FONT_NORMAL)
self.lbl_file_info.pack(side="left", padx=10)
# 2. 메인 컨텐츠 (좌: 미리보기 / 우: 매핑)
self.frame_mid = ctk.CTkFrame(self)
self.frame_mid.pack(pady=5, padx=10, fill="both", expand=True)
# --- 좌측 패널 (미리보기) ---
self.frame_left = ctk.CTkFrame(self.frame_mid, width=700)
self.frame_left.pack(side="left", fill="both", expand=True, padx=(0, 5))
# 안내 문구 및 수동 버튼
self.frame_left_header = ctk.CTkFrame(self.frame_left, fg_color="transparent")
self.frame_left_header.pack(fill="x", pady=5)
self.lbl_preview = ctk.CTkLabel(self.frame_left_header,
text="📂 2. 데이터 미리보기",
font=FONT_BOLD, text_color="#FFA500")
self.lbl_preview.pack(side="left", padx=5)
self.btn_set_header = ctk.CTkButton(self.frame_left_header, text="👈 선택한 줄을 제목으로 설정",
command=self.set_header_manual, font=FONT_SMALL,
fg_color="#444444", hover_color="#666666")
self.btn_set_header.pack(side="right", padx=5)
# Treeview
style = ttk.Style()
style.theme_use("default")
style.configure("Treeview", background="#2b2b2b", fieldbackground="#2b2b2b",
foreground="white", rowheight=25, font=FONT_NORMAL)
style.configure("Treeview.Heading", background="#3a3a3a", foreground="white", font=FONT_BOLD)
style.map('Treeview', background=[('selected', '#1f538d')])
self.tree = ttk.Treeview(self.frame_left)
self.tree.pack(fill="both", expand=True, padx=5, pady=5)
vsb = ttk.Scrollbar(self.frame_left, orient="vertical", command=self.tree.yview)
vsb.pack(side='right', fill='y')
self.tree.configure(yscrollcommand=vsb.set)
# 이벤트 바인딩
self.tree.bind("<Double-1>", self.on_tree_double_click)
self.lbl_selected_header = ctk.CTkLabel(self.frame_left, text="현재 제목(Header): 1번째 줄 (자동설정)",
text_color="#55FF55", font=FONT_NORMAL)
self.lbl_selected_header.pack(pady=5)
# --- 우측 패널 (매핑) ---
self.frame_right_container = ctk.CTkFrame(self.frame_mid, width=400)
self.frame_right_container.pack(side="right", fill="both", padx=(5, 0))
# 매핑 스크롤 영역
self.frame_right = ctk.CTkScrollableFrame(self.frame_right_container, label_text="[단계 3] 매핑 설정",
label_font=FONT_BOLD)
self.frame_right.pack(fill="both", expand=True, padx=5, pady=5)
# 3. 하단: 변환 실행
self.frame_bottom = ctk.CTkFrame(self)
self.frame_bottom.pack(pady=10, padx=10, fill="x")
self.progress = ctk.CTkProgressBar(self.frame_bottom)
self.progress.set(0)
self.progress.pack(fill="x", padx=20, pady=(10, 5))
self.btn_convert = ctk.CTkButton(self.frame_bottom, text="🚀 변환 및 저장하기 (Start)",
command=self.start_conversion,
font=FONT_BOLD, fg_color="green", state="disabled", height=50)
self.btn_convert.pack(pady=10)
def load_file(self):
file_path = filedialog.askopenfilename(filetypes=[("Excel files", "*.xlsx *.xls *.csv")])
if not file_path: return
self.file_path = file_path
self.lbl_file_info.configure(text=f"...{os.path.basename(file_path)}", text_color="white")
try:
# 1. 파일 읽기
if file_path.endswith('.csv'):
try: self.df_raw = pd.read_csv(file_path, header=None, nrows=50, encoding='utf-8')
except: self.df_raw = pd.read_csv(file_path, header=None, nrows=50, encoding='cp949')
else:
self.df_raw = pd.read_excel(file_path, header=None, nrows=50)
# 2. NaN 처리 (빈 문자열로)
self.df_raw = self.df_raw.fillna("")
self.df_raw = self.df_raw.astype(str)
# 3. 미리보기 갱신
self.update_treeview(self.df_raw)
# [핵심 변경] 파일 열자마자 0번 줄을 기준으로 매핑 UI 강제 생성
self.header_row_idx = 0
self.lbl_selected_header.configure(text="현재 제목(Header): 1번째 줄 (자동설정)", text_color="#55FF55")
self.generate_mapping_ui() # <--- 즉시 실행!
self.btn_convert.configure(state="normal")
except Exception as e:
messagebox.showerror("에러", f"파일 읽기 실패: {e}")
def update_treeview(self, df):
self.tree.delete(*self.tree.get_children())
self.tree["columns"] = list(df.columns)
self.tree["show"] = "headings"
for col in df.columns:
self.tree.heading(col, text=f"Col {col}")
self.tree.column(col, width=80)
for i, row in df.iterrows():
self.tree.insert("", "end", iid=str(i), values=list(row))
def on_tree_double_click(self, event):
item_id = self.tree.identify('item', event.x, event.y)
if not item_id: return
self.set_header_logic(int(item_id))
def set_header_manual(self):
# 현재 선택된 아이템 가져오기
selected = self.tree.selection()
if not selected:
messagebox.showwarning("알림", "왼쪽 표에서 제목으로 쓸 줄을 먼저 클릭해서 선택해주세요.")
return
self.set_header_logic(int(selected[0]))
def set_header_logic(self, row_idx):
self.header_row_idx = row_idx
self.lbl_selected_header.configure(text=f"현재 제목(Header): {self.header_row_idx + 1}번째 줄", text_color="#55FF55")
self.generate_mapping_ui()
def generate_mapping_ui(self):
# 기존 위젯 삭제
for widget in self.frame_right.winfo_children():
widget.destroy()
self.mapping_entries = {}
# 선택된 행의 데이터 가져오기
try:
headers = self.df_raw.iloc[self.header_row_idx].values
except:
return # 데이터가 아직 없을 때
count = 0
for i, col_name in enumerate(headers):
# 내용이 'nan' 이거나 공백이면 무시 (Unnmaed 등)
col_str = str(col_name).strip()
if not col_str or col_str.lower() == 'nan':
continue
count += 1
row_frame = ctk.CTkFrame(self.frame_right, fg_color="transparent")
row_frame.pack(fill="x", pady=2)
# 라벨 (너무 길면 자르기)
display_name = (col_str[:15] + '..') if len(col_str) > 15 else col_str
ctk.CTkLabel(row_frame, text=f"{display_name}", width=120, anchor="w", font=FONT_NORMAL).pack(side="left", padx=5)
entry = ctk.CTkEntry(row_frame, placeholder_text="DB ID / year / month", font=FONT_NORMAL)
entry.pack(side="right", fill="x", expand=True, padx=5)
if col_str in self.saved_mapping:
entry.insert(0, self.saved_mapping[col_str])
self.mapping_entries[col_str] = entry
if count == 0:
ctk.CTkLabel(self.frame_right, text="⚠️ 선택한 줄에 유효한 글자가 없습니다.\n다른 줄을 선택해주세요.",
text_color="#FF5555", font=FONT_NORMAL).pack(pady=20)
def start_conversion(self):
self.progress.set(0.1)
self.update_idletasks()
try:
mapping_dict = {}
special_cols = []
for original, entry in self.mapping_entries.items():
target = entry.get().strip()
if target:
mapping_dict[original] = target
self.saved_mapping[original] = target
if target.lower() in ['date', 'year', 'month', 'day']:
special_cols.append(target.lower())
self.save_mapping()
if not mapping_dict:
messagebox.showwarning("경고", "매핑된 컬럼이 하나도 없습니다.")
self.progress.set(0)
return
self.progress.set(0.3)
# 실제 데이터 다시 읽기
if self.file_path.endswith('.csv'):
try: df_real = pd.read_csv(self.file_path, header=self.header_row_idx, encoding='utf-8')
except: df_real = pd.read_csv(self.file_path, header=self.header_row_idx, encoding='cp949')
else:
df_real = pd.read_excel(self.file_path, header=self.header_row_idx)
# 컬럼명 정리
df_real.columns = [str(c).strip() for c in df_real.columns]
# 매핑 적용
valid_cols = [c for c in mapping_dict.keys() if c in df_real.columns]
df_sel = df_real[valid_cols].copy()
df_sel.rename(columns=mapping_dict, inplace=True)
df_sel.columns = [c.lower() if c.lower() in ['date', 'year', 'month', 'day'] else c for c in df_sel.columns]
self.progress.set(0.5)
# 날짜 생성 로직
if 'date' in df_sel.columns:
df_sel['date'] = pd.to_datetime(df_sel['date'], errors='coerce')
elif 'year' in df_sel.columns and 'month' in df_sel.columns:
df_sel['year'] = pd.to_numeric(df_sel['year'], errors='coerce')
df_sel['month'] = pd.to_numeric(df_sel['month'], errors='coerce')
if 'day' not in df_sel.columns:
df_sel['day'] = 1
is_monthly_data = True
else:
df_sel['day'] = pd.to_numeric(df_sel['day'], errors='coerce')
is_monthly_data = False
df_sel.dropna(subset=['year', 'month'], inplace=True)
df_sel['date'] = pd.to_datetime(df_sel[['year', 'month', 'day']])
if is_monthly_data:
df_sel['date'] = df_sel['date'] + MonthEnd(0)
df_sel.dropna(subset=['date'], inplace=True)
df_sel['date'] = df_sel['date'].dt.strftime('%Y-%m-%d')
cols_to_drop = [c for c in ['year', 'month', 'day'] if c in df_sel.columns]
df_sel.drop(columns=cols_to_drop, inplace=True)
self.progress.set(0.7)
# Melt
melted = pd.melt(df_sel, id_vars=['date'], var_name='plant_id', value_name='generation')
melted['generation'] = pd.to_numeric(melted['generation'], errors='coerce')
melted.dropna(subset=['generation'], inplace=True)
melted = melted[melted['generation'] > 0]
self.progress.set(0.9)
# 저장
save_path = os.path.splitext(self.file_path)[0] + "_processed.xlsx"
with pd.ExcelWriter(save_path, engine='openpyxl') as writer:
melted.to_excel(writer, sheet_name='ALL_DATA', index=False)
for plant in melted['plant_id'].unique():
safe_name = str(plant)[:30].replace('/', '_')
melted[melted['plant_id'] == plant].to_excel(writer, sheet_name=safe_name, index=False)
self.progress.set(1.0)
messagebox.showinfo("완료", f"변환 성공!\n{save_path}")
except Exception as e:
self.progress.set(0)
messagebox.showerror("오류", str(e))
def load_mapping(self):
if os.path.exists(self.mapping_file):
try:
with open(self.mapping_file, 'r', encoding='utf-8') as f: return json.load(f)
except: return {}
return {}
def save_mapping(self):
try:
with open(self.mapping_file, 'w', encoding='utf-8') as f:
json.dump(self.saved_mapping, f, ensure_ascii=False, indent=4)
except: pass
if __name__ == "__main__":
app = SolarApp()
app.mainloop()