326 lines
14 KiB
Python
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() |