chore: merge subprojects into monorepo layout

This commit is contained in:
haneulai 2026-06-15 12:54:11 +09:00
parent 072d038410
commit 1724a80426
14 changed files with 2466 additions and 0 deletions

129
AGENTS.md Normal file
View File

@ -0,0 +1,129 @@
# SolarPower Agents Guide
This document provides context, commands, and guidelines for AI agents working on the SolarPower codebase.
## 1. Project Overview
SolarPower is an integrated solar power monitoring system consisting of:
- **Crawler**: Python-based data collector running on Oracle Cloud (formerly Synology NAS). Uses Tailscale to route via NAS IP to avoid blocks.
- **API Server**: FastAPI backend on Oracle Cloud acting as a middleware.
- **App**: React Native (Expo) mobile/web application for monitoring.
- **Database**: Supabase (PostgreSQL) for data persistence.
## 2. Directory Structure
- `crawler/`: Python crawlers and scheduler.
- `crawlers/`: Individual site crawler implementations.
- `crawler_manager.py`: Smart scheduler using SQLite (`crawler_manager.db`).
- `api_server/`: FastAPI backend.
- `app/routers/`: API endpoints (`plants`, `stats`, `upload`).
- `app/core/`: Configuration and utilities.
- `app/`: React Native Expo frontend.
- `components/`: Reusable UI components.
- `screens/`: Application screens.
## 3. Development Commands
### 📱 Mobile App (`app/`)
- **Environment**: Node.js, Expo
- **Install**: `npm install`
- **Run (Dev)**: `npx expo start`
- Press `w` for web, `a` for Android.
- **Build (Web)**: `npx expo export -p web`
- Outputs to `dist/` directory.
- Use this command for deployment builds.
- **Lint**: No strict ESLint config. Follow existing code patterns (functional components, hooks).
- **Test**:
- **E2E**: Use `playwright` skill/tool to verify the web build running on a local server.
- **Manual**: Run `npx expo start` and use Expo Go.
### ⚡ API Server (`api_server/`)
- **Environment**: Python 3.10+
- **Install**: `pip install -r requirements.txt`
- **Run (Dev)**: `uvicorn app.main:app --reload`
- Runs on port 8000 by default.
- **Run (Prod)**: Managed via systemd: `sudo systemctl restart solar-api`
- **Docs**: Access Swagger UI at `http://localhost:8000/docs`
- **Test**:
- No automated test suite (pytest) currently.
- Verify endpoints manually using `curl` or Swagger UI.
### 🐍 Crawler (`crawler/`)
- **Environment**: Python 3.10+
- **Install**: Manual dependency installation (requests, python-dotenv, supabase).
- **Run (Manual)**: `python main.py`
- Runs in `LEARNING` or `OPTIMIZED` mode based on `crawler_manager.db`.
- **Run (Force)**: `python main.py --force` (Ignores scheduler constraints)
- **GUI Tool**: `python crawler_gui.py` (For historical data recovery and log cleaning)
- **Testing**:
- No standard test runner. Use standalone scripts in `tests/`.
- **Run Single Test**: `python tests/debug_kremc.py` (or similar script for specific site).
## 4. Code Style & Standards
### 🐍 Python (Crawler & API)
- **Style**: Follow PEP 8 standards.
- **Indentation**: 4 spaces.
- **Typing**: Use type hints where possible (`def func(a: int) -> str:`).
- **Frameworks**:
- **FastAPI**: Use Pydantic models for request/response schemas.
- **Crawler**: Use `crawler_manager` for scheduling. Do not hardcode run times.
- **Imports**: Group standard lib, third-party, then local modules.
- **Error Handling**:
- API: Raise `HTTPException` with clear status codes.
- Crawler: Catch exceptions per site to prevent one failure from stopping others.
- **Async**: API Server uses `async/await`. Crawler is synchronous (mostly).
- **Naming**: `snake_case` for functions/variables, `PascalCase` for classes.
### ⚛️ JavaScript/React Native (`app/`)
- **Style**: Functional Components with Hooks (`useState`, `useEffect`).
- **Indentation**: 2 spaces.
- **Quotes**: Single quotes `'` preferred for strings.
- **Semicolons**: Always use semicolons.
- **Naming**: `camelCase` for variables/functions, `PascalCase` for components.
- **Styling**: Use `StyleSheet.create({})`. Avoid inline style objects if possible.
- **Navigation**: Uses `@react-navigation`. Ensure strict typing for route params.
- **Libraries**:
- Charts: `react-native-gifted-charts`
- UI: `react-native-svg`, `expo-linear-gradient`
- **State**: Local state preferred. Keep components pure where possible.
## 5. Deployment Workflows
### 🚀 Web App Deployment
1. **Build**: Run `npx expo export -p web` locally to generate `dist/`.
2. **Upload**: Use SCP to transfer `dist/` to the server.
- Example: `scp -r dist user@server:~/dist_temp`
3. **Deploy**: Move the uploaded folder to the web root (`/var/www/html/dist`).
- Command: `ssh user@server "sudo rm -rf /var/www/html/dist && sudo mv ~/dist_temp /var/www/html/dist"`
4. **Refresh**: `sudo systemctl reload nginx` (optional, for cache clearing).
### ☁️ Server Deployment
1. **Push**: Commit and push changes to the remote Git repository.
2. **Pull**: SSH into the server and pull changes.
- Command: `ssh user@server "cd ~/solorpower_server && git pull"`
3. **Restart**: Restart the API service.
- Command: `sudo systemctl restart solar-api`
## 6. Architecture & Patterns
### 🧠 Crawler Scheduler (`crawler_manager.py`)
- **LEARNING Mode**: Runs every cycle to determine update patterns.
- **OPTIMIZED Mode**: Runs only during specific 10-minute windows around the learned update time.
- **Storage**: Uses local SQLite (`site_rules` table) to persist scheduling rules.
### 🌐 API Structure
- **Routers**:
- `plants`: Basic plant info and real-time status.
- `stats`: Aggregated statistics (daily, monthly, yearly).
- `upload`: Excel file processing for manual data entry.
- **CORS**: Configured to allow all origins (`*`) in development.
## 7. Critical Rules for Agents
1. **Timezones**: All internal logic should handle KST (UTC+9) correctly. Be careful with UTC conversions in DB interactions.
2. **Data Integrity**: `crawler_manager` prevents duplicate runs. Respect `should_run` checks.
3. **Error Recovery**: Crawlers must be robust against network failures (retry logic).
4. **Secrets**: Never commit `.env` files. Use `python-dotenv`.
5. **Testing**: Always verify changes locally before deploying.
- **App**: Check UI changes on local web server.
- **API**: Check endpoints response.
- **Crawler**: Run debug scripts.
6. **Networking**: When modifying crawlers, be aware that Oracle Cloud uses Tailscale to route traffic through the Synology NAS (Exit Node) to avoid IP blocks from monitoring sites. Ensure Tailscale is active on the host.

335
README.md Normal file
View File

@ -0,0 +1,335 @@
# ☀️ SolarPower 태양광 발전 통합 관제 시스템
태양광 발전소의 실시간 발전 데이터를 수집하고 모니터링하는 통합 관제 시스템입니다.
---
## 📋 목차
1. [시스템 개요](#시스템-개요)
2. [전체 아키텍처](#전체-아키텍처)
3. [인프라 구성](#인프라-구성)
4. [크롤러 (Synology NAS / PC)](#크롤러-synology-nas--pc)
5. [API 서버 (Oracle Cloud)](#api-서버-oracle-cloud)
6. [모바일 앱 (React Native/Expo)](#모바일-앱-react-nativeexpo)
7. [데이터베이스 (Supabase)](#데이터베이스-supabase)
8. [문제 해결 가이드](#문제-해결-가이드)
---
## 시스템 개요
### 지원 발전소
| 호기 | 크롤러 타입 | 모니터링 시스템 | 비고 |
|------|------------|----------------|------|
| 1호기 | NREMS | nrems.co.kr | 1,2호기 통합 계정 |
| 2호기 | NREMS | nrems.co.kr | 인버터별 분리 처리 |
| 3호기 | NREMS | nrems.co.kr | |
| 4호기 | NREMS | nrems.co.kr | |
| 5호기 | KREMC | kremc.kr | JWT 인증, 월 단위 수집 |
| 6호기 | Sun-WMS | sun-wms.com | 암호화된 페이로드, 월 단위 수집 |
| 8호기 | Hyundai | hyundai-es.co.kr | 월별 통계 API 사용 (초고속) |
| 9호기 | NREMS | nrems.co.kr | |
| 10호기 | CMSolar | cmsolar2.kr | |
---
## 전체 아키텍처
```mermaid
flowchart TD
subgraph 외부시스템["외부 모니터링 시스템"]
NREMS["NREMS<br/>(1,2,3,4,9호기)"]
KREMC["KREMC<br/>(5호기)"]
SUNWMS["Sun-WMS<br/>(6호기)"]
HYUNDAI["Hyundai<br/>(8호기)"]
CMSOLAR["CMSolar<br/>(10호기)"]
end
subgraph CLOUD["Oracle Cloud (CRAWLER & API)"]
FASTAPI["FastAPI Server<br/>(Port 8000)"]
NGINX["Nginx Reverse Proxy<br/>(Port 443)"]
CRAWLER["Python Crawler<br/>(Tailscale Routing)"]
end
subgraph NAS["Synology NAS (Exit Node)"]
TAILSCALE["Tailscale Node<br/>(Home IP Mapping)"]
end
subgraph SUPABASE["Supabase (PostgreSQL)"]
DB_PLANTS["plants 테이블"]
DB_LOGS["solar_logs 테이블"]
DB_DAILY["daily_stats 테이블"]
end
subgraph CLIENT["클라이언트"]
APP["React Native App"]
WEB["Web Browser"]
end
NREMS --> CRAWLER
KREMC --> CRAWLER
SUNWMS --> CRAWLER
HYUNDAI --> CRAWLER
CMSOLAR --> CRAWLER
CRAWLER -.->|Tailscale Tunnel| TAILSCALE
TAILSCALE -.->|Proxy Request| 외부시스템
CRAWLER -->|INSERT| DB_LOGS
CRAWLER -->|UPSERT| DB_DAILY
FASTAPI -->|SELECT| DB_PLANTS
FASTAPI -->|SELECT| DB_LOGS
FASTAPI -->|SELECT| DB_DAILY
NGINX --> FASTAPI
APP -->|HTTPS| NGINX
WEB -->|HTTPS| NGINX
```
---
## 인프라 구성
### 서버 접속 정보
| 서버 | 용도 | 접속 명령어 |
|------|------|------------|
| **Oracle Cloud** | API 서버 & 크롤러 | `ssh -i "~/.ssh/holdem_server.key" ubuntu@140.245.73.212` |
| **Synology NAS** | 데이터 백업 & Tailscale Exit Node | `ssh -p2022 haneulai@192.168.45.151` |
### 도메인
| 도메인 | 용도 |
|--------|------|
| `https://solorpower.dadot.net` | API 서버 (Nginx 리버스 프록시) |
---
## 크롤러 (Oracle Cloud / PC)
### 위치
```
d:\dev\etc\SolorPower\crawler\ (Local PC)
/home/ubuntu/SolorPower/crawler/ (Oracle Cloud)
```
### 네트워크 구성 (Tailscale)
일부 모니터링 사이트에서 Oracle Cloud IP가 차단되는 문제를 해결하기 위해 **Tailscale**을 사용하여 네트워크를 구성하였습니다.
- **주 호스트**: Oracle Cloud (크롤러 실행)
- **Exit Node**: Synology NAS (가정용 IP 제공)
- **동작**: 크롤링 요청 시 Tailscale 터널을 통해 NAS의 IP를 사용하여 외부 시스템에 접근합니다.
### 디렉토리 구조
```
crawler/
├── main.py # 메인 실행 파일 (실시간 수집용)
├── crawler_gui.py # GUI 관리 도구 (과거 데이터 수집용) ← NEW!
├── config.py # 발전소/인증 설정
├── database.py # Supabase 연동
├── fetch_history.py # 과거 데이터 수집 로직
├── .env # 환경 변수 (SUPABASE_URL, SUPABASE_KEY)
└── crawlers/
├── __init__.py # 크롤러 라우팅
├── base.py # 공통 유틸리티
├── nrems.py # NREMS 크롤러
├── kremc.py # KREMC 크롤러
├── sun_wms.py # Sun-WMS 크롤러
├── hyundai.py # Hyundai 크롤러
└── cmsolar.py # CMSolar 크롤러
```
### GUI 관리 도구 (NEW)
과거 데이터를 편리하게 수집하고 복구하기 위한 GUI 도구가 추가되었습니다.
1. **실행**:
```bash
python crawler_gui.py
```
2. **기능**:
- 발전소별 실시간 상태 확인
- **과거 데이터 수집**: 날짜 범위를 지정하여 일별/월별 데이터 일괄 수집
- **로그 정리**: 미래 날짜로 잘못 들어간 오염 데이터 삭제 기능
### 자동 수집 (Cron)
```bash
# 30분마다 실행 (Oracle Cloud 환경)
*/30 * * * * cd /home/ubuntu/SolorPower/crawler && /home/ubuntu/SolorPower/crawler/venv/bin/python main.py >> cron.log 2>&1
```
### 크롤러별 개선 사항 (2026.01)
- **공통**: 미래 날짜 데이터(UTC/KST 혼동)가 저장되는 버그 수정 및 자동 청소 로직 추가.
- **NREMS**: 일별 데이터 수집 시 월 단위로 분할 요청하여 끊김 방지.
- **KREMC**: 5호기 인증 토큰 파싱 개선 및 월 단위 배치 수집 적용.
- **Sun-WMS**: 6호기 세션 유지 및 월 단위 수집 안정화.
- **Hyundai**: 8호기 `getSolraMonthWork` API를 활용하여 1년치 데이터를 수십 초 내에 수집하도록 최적화.
---
## API 서버 (Oracle Cloud)
### 위치
```
/home/ubuntu/SolorPower/api_server/
```
### 환경 설정 (.env)
```env
SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6...
DEBUG=false
```
### 서비스 관리
`sudo systemctl restart solar-api`
### API 엔드포인트
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/plants/{plant_id}/stats` | 발전소 통계 조회 (일/월/년) |
| GET | `/plants/{plant_id}/stats/today` | 오늘(또는 특정일) 시간별 상세 조회 |
| POST | `/plants/{plant_id}/upload` | 엑셀 데이터 업로드 (일간) |
| POST | `/plants/{plant_id}/upload/monthly` | 엑셀 데이터 업로드 (월간) |
**파라미터 설명**:
- `period`: `day` (일별), `month` (월별), `year` (연도별)
- `year`: (옵션) 조회 기준 연도
- `month`: (옵션) `period=day`시 조회할 월
- `date`: (옵션) `stats/today`시 조회할 날짜 (YYYY-MM-DD)
---
## 모바일 앱 & 웹 (React Native/Expo)
### 위치
```
d:\dev\etc\SolorPower\app\
```
### 주요 기능
- **실시간/통계 통합**: 대시보드 및 상세 차트(일/월/년).
- **날짜 네비게이션**: `◀ 2026년 1월 ▶` 등의 컨트롤로 자유로운 과거 데이터 탐색.
- **엑셀 업로드**:
- **일간**: `date`, `generation` 또는 `year, month, day, kwh` 포맷 지원.
- **월간**: `year, month, kwh` 포맷 지원.
- 별칭(`발전량`, `kwh` 등) 및 셀 병합 처리 지원.
- **반응형 웹 지원**: PC/모바일 브라우저 최적화.
---
## 웹 앱 배포 가이드 (Web Deployment)
React Native(Expo) 앱을 웹으로 빌드하고 Nginx 서버에 배포하는 과정입니다.
### 1. 웹 빌드 (Local PC)
```bash
cd d:\dev\etc\SolorPower\app
npm run export:web
```
- `dist` 폴더에 정적 웹 파일들이 생성됩니다.
### 2. 서버 업로드 (SCP)
빌드된 `dist` 폴더를 Oracle Cloud 서버의 웹 루트로 전송합니다.
*(참고: `/var/www/html/` 권한 문제로 인해 임시 폴더로 전송 후 `sudo`로 이동해야 할 수 있습니다)*
```bash
# 1. 홈 디렉토리로 전송
scp -i "~/.ssh/holdem_server.key" -r dist ubuntu@140.245.73.212:~/dist_temp
# 2. 서버 접속 후 이동
ssh -i "~/.ssh/holdem_server.key" ubuntu@140.245.73.212
sudo rm -rf /var/www/html/dist
sudo mv ~/dist_temp /var/www/html/dist
```
### 3. Nginx 설정 (서버 분석 결과)
실제 운영 중인 Nginx 설정 (`/etc/nginx/sites-enabled/solorpower`)의 핵심 구조입니다.
**라우팅 규칙**:
- **Frontend**: `/var/www/html/dist` (React Static Files)
- **Backend**: `http://127.0.0.1:8000` (FastAPI)
- `/plants` (데이터/통계 API)
- `/docs`, `/openapi.json` (API 문서)
```nginx
server {
listen 80;
server_name solorpower.dadot.net;
# HTTPS 리다이렉트 생략...
listen 443 ssl;
# SSL 인증서 설정 생략...
# 1. React Frontend (정적 파일)
location / {
root /var/www/html/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# 2. FastAPI Backend (API 프록시)
# plants(데이터), docs(문서) 경로만 백엔드로 토스
location ~ ^/(plants|docs|openapi.json) {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 4. 적용
```bash
sudo systemctl reload nginx
```
이제 `https://solorpower.dadot.net` 에서 배포된 웹 앱을 확인할 수 있습니다.
---
## 데이터베이스 (Supabase)
### 주요 테이블
1. **plants**: 발전소 기본 정보.
2. **solar_logs**: 실시간 발전 로그 (10분/30분 단위). `created_at` 기준.
3. **daily_stats**: 일별 발전량 통계. `date` 기준. (과거 데이터는 여기에 저장됨)
4. **monthly_stats**: 월별 발전량 통계. `month` 기준.
---
## 개발 히스토리
### 2026-04-22
- **크롤러 인프라 이전**: 크롤러 실행 환경을 Synology NAS에서 Oracle Cloud로 이전.
- **네트워크 우회 설정**: Oracle Cloud IP 차단 이슈 대응을 위해 Tailscale을 도입, Synology NAS를 Exit Node로 설정하여 가정용 IP로 크롤링 수행.
- **안정성 강화**: 클라우드 환경 이전을 통해 NAS 리소스 부하 감소 및 수집 안전성 확보.
### 2026-01-30
- **데이터 복구 및 안정화**: 1월 28-29일 데이터 누락 복구 완료.
- **자동 통계 집계 개선**: `daily_summary.py`가 날짜 파라미터 없이 실행될 경우 자동으로 '어제' 날짜를 기준으로 집계하도록 수정. (새벽 실행 안전성 확보)
- **월간 통계 자동화**: 일일 집계 시 해당 월의 마지막 날인 경우, 자동으로 `monthly_stats`까지 집계/갱신하는 로직 추가.
- **Git 저장소 분리**:
- `crawler`: https://gitea.dadot.synology.me/haneulai/solorpower_crawler.git
- `app`: https://gitea.dadot.synology.me/haneulai/solorpower_app.git
### 2026-01-28
- **Stats API 고도화**: 날짜/월 탐색을 위한 파라미터(`month`, `date`) 추가.
- **연간 통계 개선**: 단일 연도 조회에서 '해당 연도 포함 최근 5년' 조회로 변경.
- **엑셀 업로드 유연성**: `year, month, day` 컬럼이 분리된 파일도 자동 인식 및 처리.
- **Frontend 개선**: 차트 상단 날짜 네비게이션(`◀ ▶`) 추가, 조회 기간 범위 표시 강화.
### 2026-01-27
- **Crawler 구조화**: `crawler_manager.py` 도입 (스마트 스케줄링, 학습/최적화 모드).
- **GUI & Documentation**: 관리자 GUI 기능 확장 및 `crawler_structure.md` 문서화.
- **과거 데이터 수집 최적화**: 모든 크롤러 월 단위 분할 수집 지원.
### 2026-01-23
- 통계 조회 API 추가 및 엑셀 업로드 API 구현.
### 2026-01-22
- 대시보드 필드명 매핑 수정.

33
app/server.log Normal file
View File

@ -0,0 +1,33 @@
Starting up http-server, serving dist
http-server version: 14.1.1
http-server settings:
CORS: disabled
Cache: 3600 seconds
Connection Timeout: 120 seconds
Directory Listings: visible
AutoIndex: visible
Serve GZIP Files: false
Serve Brotli Files: false
Default File Extension: none
Available on:
http://172.31.160.1:3004
http://192.168.45.31:3004
http://127.0.0.1:3004
Hit CTRL-C to stop the server
[2026-02-12T01:44:51.238Z] "HEAD /" "curl/8.12.1"
(node:1368) [DEP0066] DeprecationWarning: OutgoingMessage.prototype._headers is deprecated
(Use `node --trace-deprecation ...` to show where the warning was created)
[2026-02-12T01:46:00.070Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
[2026-02-12T01:46:00.119Z] "GET /_expo/static/js/web/AppEntry-943aad0f5c4e74ff22d7c538b45fdd54.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
[2026-02-12T01:46:00.399Z] "GET /favicon.ico" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
[2026-02-12T01:46:00.400Z] "GET /favicon.ico" Error (404): "Not found"
[2026-02-12T01:51:33.504Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
[2026-02-12T01:52:17.173Z] "HEAD /" "curl/8.12.1"
[2026-02-12T01:52:33.791Z] "GET /" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
[2026-02-12T01:52:33.821Z] "GET /_expo/static/js/web/AppEntry-8dbed585399d63adcee0c00c59fe829b.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
[2026-02-12T01:52:34.014Z] "GET /favicon.ico" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0"
[2026-02-12T01:52:34.015Z] "GET /favicon.ico" Error (404): "Not found"

View File

@ -0,0 +1,64 @@
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
os.environ['SUPABASE_URL'] = 'https://qgelkvfjnhqwuvltmqct.supabase.co'
os.environ['SUPABASE_KEY'] = 'sb_secret_BFkr9u1Npu3asKDWTn40tg_Qnn1uKLc'
from database import get_supabase_client
import sqlite3
from pathlib import Path
client = get_supabase_client()
if not client:
print("Supabase 연결 실패")
sys.exit(1)
# 1. 오늘 KST 15:00~18:00 = UTC 06:00~09:00 의 nrems-03 solar_logs 조회
print("=" * 60)
print("[1] nrems-03 오늘 15:00~18:00 KST (solar_logs)")
print("=" * 60)
resp = client.table('solar_logs') \
.select('plant_id, current_kw, today_kwh, status, created_at') \
.eq('plant_id', 'nrems-03') \
.gte('created_at', '2026-05-14T06:00:00+00:00') \
.lte('created_at', '2026-05-14T09:30:00+00:00') \
.order('created_at') \
.execute()
for row in resp.data:
print(f" {row['created_at']} kw={row['current_kw']} today={row['today_kwh']} status={row['status']}")
print(f"{len(resp.data)}\n")
# 2. daily_stats 오늘 날짜 확인
print("=" * 60)
print("[2] daily_stats 오늘 nrems-03")
print("=" * 60)
resp2 = client.table('daily_stats') \
.select('*') \
.eq('plant_id', 'nrems-03') \
.eq('date', '2026-05-14') \
.execute()
for row in resp2.data:
print(f" {row}")
# 3. alert_history 로컬 DB 확인
print("\n" + "=" * 60)
print("[3] 로컬 alert_history (crawler_manager.db)")
print("=" * 60)
db_path = Path(__file__).parent.parent / "crawler_manager.db"
try:
with sqlite3.connect(str(db_path)) as conn:
cur = conn.cursor()
cur.execute("SELECT site_id, alert_status, last_alert_time FROM alert_history WHERE site_id='nrems-03'")
row = cur.fetchone()
if row:
print(f" site_id={row[0]} status={row[1]} last_alert={row[2]}")
else:
print(" nrems-03 alert_history 없음")
except Exception as e:
print(f" 로컬 DB 읽기 실패: {e} (서버 DB는 서버에 있음)")

48
docs/check.md Normal file
View File

@ -0,0 +1,48 @@
[프로젝트 개요]
- 태양광 발전소 통합 모니터링 플랫폼 구축
[프로젝트 배경 및 목표]
- 당사는 태양광 발전소 시공 및 관리를 전문으로 하는 기업입니다. 현재 각 발전소에 설치된 장비 제조사별로 모니터링 웹사이트가 달라, 발전량 데이터를 확인하기 위해 여러 사이트를 개별적으로 접속해야 하는 번거로움이 있습니다.
이에, 분산된 여러 제조사의 모니터링 사이트 데이터를 하나의 플랫폼으로 통합하여 보여주는 시스템을 구축하고자 합니다.
1차적으로는 통합 모니터링 기능에 집중하고, 장기적으로는 공장 에너지 관리 시스템(EMS) 및 가상발전소(VPP) 기능까지 확장 가능한 기반을 마련하는 것을 목표로 합니다.
[과업 범위]
1. 수행 범위
- 상세 기획: 요구 사항 정의, 기능 정의, 화면 설계
- UI/UX 디자인
- 프런트엔드/Client 개발 (PC Web)
- 백엔드 개발
- 서버/DB/인프라 구성
2. 상세 기능 요구 사항
2-1. 통합 모니터링 대시보드: 사용자가 등록한 모든 발전소의 발전량, 상태 등 핵심 데이터를 한 화면에서 직관적으로 파악할 수 있는 대시보드 기능을 제공합니다.
2-2. 발전소 및 데이터 연동 관리: 사용자가 각 제조사별 모니터링 사이트의 계정 정보(ID/PW)를 입력하여 자신의 발전소를 플랫폼에 등록하고, 등록된 정보를 기반으로 시스템이 자동으로 데이터를 연동(수집)하는 기능을 구현합니다.
2-3. 사용자 관리 시스템: 플랫폼을 사용하는 고객사가 직접 회원가입, 로그인, 정보 수정을 할 수 있는 회원 관리 기능을 제공합니다.
2-4. 관리자 페이지 기능: 전체 사용자 계정 관리, 데이터 연동 상태 모니터링, 시스템 공지사항 등록 등 플랫폼 운영을 위한 포괄적인 관리자 기능을 포함합니다.
[지원 디바이스 및 디자인]
1. 지원 디바이스
- PC Web (반응형 지원)
2. 디자인 가이드
- 벤치마킹 대상: 발전왕
- 브랜드 가이드라인: 자사 브랜딩 적용(화이트 라벨링 방식)을 위한 협의 필요
[기술 스택]
프로젝트에 가장 적합하다고 판단되는 기술 스택을 자유롭게 제안해 주시기 바랍니다.
[지원 자격 및 우대 사항]
1. 지원 자격
- 유사 통합 모니터링 플랫폼 개발 경험
- 에너지 관리 시스템(EMS) 또는 관련 플랫폼 개발 경험 보유 업체
2. 우대 사항
- 외부 데이터 연동 및 파싱 관련 기술 경험
- 장기적인 관점에서 협력 및 시스템 고도화가 가능한 업체
[산출물]
- 상세 기획서 (IA, 기능정의서, 화면설계서 등)
- UI/UX 디자인 원본 파일 (Figma, XD 등)
- 개발 소스코드 일체
- 서버 구성 및 인프라 관련 명세서
[계약 관련 특이 사항]
- 본 프로젝트는 1단계 사업이며, 향후 에너지 관리 시스템(EMS), 전력 중개, 가상발전소(VPP) 등으로의 단계적 기능 고도화를 계획하고 있습니다. 장기적인 파트너십을 고려하여 제안해 주시기 바랍니다.

View File

@ -0,0 +1,315 @@
# 태양광 발전소 통합 모니터링 플랫폼 — 종합 분석 보고서
> 작성일: 2026-04-22
> 참조 문서: `docs/check.md` (과업 요구사항), `README.md` (현 시스템 현황)
---
## 1. 프로젝트 전체상 (Vision)
### 1-1. 현재 시스템 vs. 목표 시스템
| 구분 | 현재 SolarPower | 목표 플랫폼 (check.md) |
|------|----------------|----------------------|
| **사용 주체** | 내부 관리자 1인 | 다수의 외부 고객사 (B2B) |
| **발전소 수** | 약 9개 (고정) | 무제한 (사용자가 직접 등록) |
| **크롤러 관리** | 개발자가 직접 코딩 | 사용자 계정 기반 자동 생성 |
| **UI** | React Native App (모바일 중심) | PC Web 반응형 (발전왕 수준) |
| **인프라 격리** | 단일 테넌트 | 멀티 테넌트 (고객사 간 데이터 완전 격리) |
| **확장 목표** | 현행 모니터링 유지 | EMS, VPP까지 단계적 확장 |
### 1-2. 제안 아키텍처 개요
```mermaid
flowchart TD
subgraph CLIENT["클라이언트 (PC Web)"]
USER["일반 사용자\n(발전소 등록/모니터링)"]
ADMIN["관리자\n(계정/시스템 관리)"]
end
subgraph PLATFORM["플랫폼 코어 (Oracle Cloud)"]
NEXTJS["Next.js\nFrontend (SSR)"]
FASTAPI["FastAPI\nBackend (REST API)"]
SCHEDULER["Crawler Scheduler\n(사용자별 독립 스케줄)"]
SECRET_MGR["Secret Manager\n(AES-256 암호화)"]
QUEUE["Task Queue\n(Redis/Celery)"]
end
subgraph CRAWLERS["수집 레이어"]
PROXY["Residential Proxy Pool\n(IP 차단 우회)"]
HEADLESS["Headless Browser\nCluster (Playwright)"]
end
subgraph EXTERNAL["외부 제조사 시스템"]
NREMS["NREMS"]
KREMC["KREMC"]
SUNWMS["Sun-WMS"]
ETC["기타 제조사..."]
end
subgraph DATA["데이터 레이어"]
SUPABASE["Supabase\n(PostgreSQL + RLS)"]
TIMESERIES["시계열 DB\n(향후 EMS/VPP용)"]
end
USER --> NEXTJS
ADMIN --> NEXTJS
NEXTJS --> FASTAPI
FASTAPI --> SECRET_MGR
FASTAPI --> SCHEDULER
SCHEDULER --> QUEUE
QUEUE --> HEADLESS
HEADLESS --> PROXY
PROXY --> NREMS & KREMC & SUNWMS & ETC
FASTAPI --> SUPABASE
HEADLESS --> SUPABASE
```
---
## 2. 기능별 위험 요소 및 해결 방안
### 2-1. 핵심 기능: 발전소 등록 및 자동 데이터 연동
이 기능은 전체 프로젝트에서 **기술적 난이도와 리스크가 가장 높은** 부분입니다.
#### 🔴 위험 1: 사용자 계정 정보(ID/PW) 보안
**문제:**
- 타사 서비스의 평문 비밀번호를 DB에 저장하는 것은 법적·윤리적 리스크를 내포합니다.
- 서버 침해 시 사용자 계정 유출로 인한 2차 피해 및 배상 책임이 발생합니다.
- 개인정보보호법 및 정보통신망법 위반 소지가 있습니다.
**해결 방안:**
```
1. 양방향 암호화 필수 적용
- 저장: AES-256-GCM 암호화 후 DB 저장
- 복호화 키: 애플리케이션 서버 환경변수 또는 전용 KMS(Key Management Service)에 분리 보관
- DB 유출 시에도 암호화 키 없이는 복호화 불가
2. 저장 범위 최소화
- 최초 로그인 성공 후 세션 쿠키/토큰만 저장하고 원본 PW는 메모리에서 즉시 파기
- 토큰 갱신 주기에만 PW 재사용 (접근 빈도 최소화)
3. 사용자 동의 및 약관
- 계정 정보 위탁 보관에 대한 명시적 동의 받기
- 개인정보 처리 방침에 해당 내용 명기
```
#### 🔴 위험 2: 제조사 사이트의 자동화 탐지 및 차단
**문제:**
- 다수의 계정이 동일 IP에서 짧은 시간 내 로그인 → 보안 시스템에 의해 IP 차단
- 사용자 계정이 자동 잠금(Lock) 처리되는 최악의 UX 발생
- 2단계 인증(MFA), 캡차(CAPTCHA) 등의 방어 기제
**해결 방안:**
```
1. 분산 프록시 전략
- Residential Proxy Pool 사용 (실제 가정용 IP 활용)
- 사용자별 고정 프록시 IP 할당 (한 IP = 한 계정 원칙)
- 예시 서비스: Bright Data, Oxylabs, IPRoyal
2. 인간 행동 시뮬레이션
- requests 방식 → Playwright/Puppeteer 헤드리스 브라우저로 전환
- 로그인 간격을 랜덤하게 설정 (1~5분 지연)
- User-Agent, 쿠키, 세션 헤더 완전 복제
3. 세션 캐싱 전략
- 로그인 성공 후 세션 쿠키를 Redis에 캐싱하여 재활용
- 세션 만료 직전에만 재로그인 수행 (로그인 빈도 최소화)
4. 실패 대응 UX
- 수집 실패 감지 즉시 → 사용자에게 이메일/앱 알림
- "계정 정보를 다시 확인해 주세요" UI 안내 흐름
```
#### 🟡 위험 3: 제조사별 크롤러 유지보수 부담
**문제:**
- 제조사 사이트 UI 변경 시 크롤러 즉시 중단
- 지원 제조사 수 증가에 따라 유지보수 공수가 선형 증가
**해결 방안:**
```
1. 크롤러 플러그인 아키텍처 도입
- 제조사별 크롤러를 독립 모듈(어댑터 패턴)로 분리
- 공통 인터페이스(BaseCrawler) 상속으로 신규 제조사 추가 용이
2. 자동 헬스체크 시스템
- 매 수집 후 데이터 유효성(날짜, 수치 범위) 자동 검증
- 연속 3회 실패 시 관리자에게 자동 알람 발송
3. 현행 SolarPower 크롤러 재활용
- NREMS, KREMC, Sun-WMS, Hyundai, CMSolar 크롤러는 이미 검증됨
- 기존 코드를 멀티테넌트 구조로 래핑하는 방식으로 빠르게 확장 가능
```
---
### 2-2. 멀티테넌시 및 데이터 격리
#### 🔴 위험: 고객사 간 데이터 혼재
**문제:**
- 현재 구조는 단일 사용자 가정 하에 설계됨
- 다수 고객사가 사용할 경우 데이터가 섞일 위험
**해결 방안:**
```
1. Supabase Row Level Security (RLS) 적용
- 모든 테이블에 tenant_id 컬럼 추가
- RLS 정책: "자신의 tenant_id에 해당하는 행만 SELECT/INSERT/UPDATE 가능"
- DB 레벨에서 격리 보장 → 애플리케이션 버그로도 타 사용자 데이터 접근 불가
2. 데이터 모델 재설계 필요
현재: plants (plant_id, name, ...)
변경: plants (plant_id, tenant_id, name, ...) ← tenant_id FK 추가
credentials (id, tenant_id, plant_id,
encrypted_id, encrypted_pw, ...) ← 신규 테이블 (암호화 계정 저장)
```
---
### 2-3. 프론트엔드 아키텍처 (PC Web)
#### 🟡 위험: Expo Web의 B2B 대시보드 한계
**문제:**
- 현재 React Native(Expo) 기반은 모바일 UX 중심
- '발전왕' 수준의 복잡한 PC 데이터 대시보드 구현에 Expo는 적합하지 않음
- 복잡한 그리드, 데이터 테이블, 인터랙티브 차트를 구현하기 어려움
**해결 방안:**
```
권장 프론트엔드 스택:
- Framework: Next.js 14 (App Router, SSR/ISR 지원)
- Styling: TailwindCSS + shadcn/ui (B2B 대시보드 컴포넌트)
- Charts: Recharts 또는 Apache ECharts (고성능 시계열 차트)
- State: Zustand + React Query (서버 상태 캐싱)
기존 Expo 앱은 모바일 알림 수신용으로 유지하고,
PC Web은 Next.js로 별도 구축하는 이원화 전략 추천
```
---
### 2-4. 인프라 확장성 (크롤링 서버)
#### 🟡 위험: 단일 Oracle Cloud 인스턴스의 한계
**문제:**
- 고객사 수 증가 → 동시 크롤링 작업 급증
- 나스의 단일 IP를 Exit Node로 사용 → IP 차단 시 전체 서비스 중단
**해결 방안:**
```
단계별 인프라 전략:
[1단계 - 소규모, ~50개 발전소]
현행 Oracle Cloud + Tailscale(나스) 구조 유지
Celery + Redis로 크롤링 태스크 큐 관리
[2단계 - 중규모, ~300개 발전소]
Residential Proxy Pool 도입 (IP 차단 우회)
크롤링 서버 인스턴스 수평 확장 (2~3대)
[3단계 - 대규모, 300개 이상]
Kubernetes 기반 크롤러 파드 자동 스케일링
제조사별 전용 크롤러 워커 분리
```
---
## 3. 법적·사업적 리스크
### 3-1. 제조사 서비스 약관 위반
| 위험 수준 | 내용 | 대응 |
|-----------|------|------|
| 🔴 높음 | 제조사 ToS의 자동화 접근 금지 조항 위반 | 서비스 초기에 제조사에 협력 제안 검토 |
| 🟡 중간 | 스크래핑 행위 자체의 법적 회색 지대 | 법무 검토 후 이용약관에 면책 조항 추가 |
| 🟢 낮음 | 수집 데이터 저작권 분쟁 | 가공된 통계 데이터만 노출, 원문 미재현 |
### 3-2. 개인정보 처리
```
필수 조치:
- 개인정보 처리 방침 수립 (개인정보보호법 준수)
- 타사 계정 정보 위탁 처리에 대한 별도 동의 절차
- 개인정보 보호책임자(CPO) 지정
- 연 1회 이상 보안 취약점 점검
```
---
## 4. 권장 기술 스택
| 영역 | 현재 | 권장 (플랫폼) | 이유 |
|------|------|-------------|------|
| **Frontend** | React Native (Expo) | **Next.js 14** | SSR, B2B 대시보드 최적화 |
| **Backend** | FastAPI | **FastAPI** 유지 | 검증된 구조 재활용 가능 |
| **Database** | Supabase (PostgreSQL) | **Supabase** 유지 + RLS 강화 | 멀티테넌트 RLS 기능 내장 |
| **크롤러** | Python requests | **Playwright** (헤드리스) | 자동화 탐지 우회 |
| **비동기 큐** | 없음 (Cron) | **Celery + Redis** | 다수 계정 동시 수집 관리 |
| **암호화** | 없음 | **AES-256-GCM + KMS** | 계정 정보 안전 보관 |
| **프록시** | Tailscale (나스) | **Residential Proxy Pool** | IP 차단 근본 해결 |
| **인프라** | Oracle Cloud 단일 | **Oracle Cloud + 수평 확장** | 트래픽 증가 대응 |
---
## 5. 단계별 개발 로드맵
### Phase 1 — MVP (2~3개월)
> 목표: 소수 고객사를 대상으로 핵심 기능 검증
- [ ] 멀티테넌트 DB 스키마 설계 (RLS 포함)
- [ ] 사용자 회원가입/로그인 (Supabase Auth 활용)
- [ ] 발전소 등록 UI (제조사 선택 → ID/PW 입력)
- [ ] 기존 크롤러를 멀티테넌트 구조로 래핑
- [ ] 기본 모니터링 대시보드 (일/월별 발전량 차트)
- [ ] 수집 실패 알림 시스템
### Phase 2 — 안정화 (2~3개월)
> 목표: 보안 강화 및 운영 안정성 확보
- [ ] AES-256 계정 암호화 체계 적용
- [ ] Playwright 헤드리스 브라우저 전환 (탐지 우회)
- [ ] Celery + Redis 비동기 크롤링 큐 도입
- [ ] 관리자 페이지 구축 (사용자/연동 상태 관리)
- [ ] 크롤러 헬스체크 및 자동 알람 시스템
### Phase 3 — 확장 (3~4개월)
> 목표: 상용 서비스 품질 달성 및 EMS 기반 마련
- [ ] Residential Proxy Pool 연동 (IP 차단 근본 해결)
- [ ] 신규 제조사 크롤러 플러그인 추가 (수요 기반)
- [ ] 시계열 DB 도입 (EMS/VPP 대비)
- [ ] 화이트 라벨링 지원 (브랜드 커스터마이징)
- [ ] 정식 제조사 API 파트너십 추진
---
## 6. 종합 평가
### ✅ 현재 프로젝트의 강점 (재활용 가능 자산)
1. **검증된 크롤러 코드**: NREMS, KREMC, Sun-WMS, Hyundai, CMSolar 등 핵심 제조사 크롤러가 이미 운영 검증됨. 플랫폼 전환 시 가장 큰 기술적 자산.
2. **스마트 스케줄러(crawler_manager)**: LEARNING/OPTIMIZED 모드 스케줄링 로직은 멀티테넌트 환경에서도 그대로 활용 가능.
3. **FastAPI + Supabase 백엔드**: 확장성 있는 구조로 재설계 부담 최소화.
### ⚠️ 핵심 해결 과제 (우선순위 순)
| 우선순위 | 과제 | 난이도 |
|---------|------|--------|
| 1 | 사용자 계정(ID/PW) 암호화 보관 체계 | ⭐⭐⭐ |
| 2 | IP 차단 우회 인프라 (Residential Proxy) | ⭐⭐⭐⭐ |
| 3 | 멀티테넌트 DB 설계 및 RLS 적용 | ⭐⭐⭐ |
| 4 | 프론트엔드 Next.js 전환 | ⭐⭐ |
| 5 | 법적 리스크 검토 (약관 위반) | ⭐⭐⭐ |
### 💡 최종 제언
> 현재 SolarPower 프로젝트는 이 플랫폼을 구축하기 위한 **훌륭한 기술적 원형(Prototype)**입니다. 그러나 단순히 "기능 추가"가 아니라 **보안, 멀티테넌시, 인프라** 세 축에서 구조적인 재설계가 필요합니다.
>
> 특히 **계정 정보 보안**과 **IP 차단 우회**는 서비스 신뢰도의 기반이므로, 개발 착수 전 이 두 가지 아키텍처를 먼저 확정한 후 나머지 기능을 쌓는 순서로 진행하기를 강력히 권장합니다.

View File

@ -0,0 +1,75 @@
# SolarPower 시스템 인프라 및 구조 가이드
이 문서는 SolarPower 시스템의 실제 서버 인프라 정보와 로컬/원격 디렉토리 구조를 정리한 가이드입니다.
## 1. 서버 접속 정보 (Oracle Cloud)
현재 시스템의 메인 로직(Crawler 및 API)이 구동되고 있는 서버 정보입니다.
* **서버 이름:** `holdem-server`
* **공인 IP (Public):** `140.245.73.212`
* **테일스케일 IP:** `100.116.0.32`
* **접속 계정:** `ubuntu`
* **SSH 키 경로:** `C:\Users\haneu\.ssh\holdem_server.key`
### 💻 SSH 접속 명령어
```powershell
# 공인 IP로 접속
ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@140.245.73.212
# Tailscale 연결 시 접속
ssh -i "C:\Users\haneu\.ssh\holdem_server.key" ubuntu@100.116.0.32
```
---
## 2. 서버 디렉토리 구조 (Remote)
서버 내 주요 서비스 설치 경로입니다.
* **`~/solorpower_crawler/`**: 실시간 데이터 수집기(Crawler)가 실행되는 메인 경로
* `main.py`: 10분마다 실행되는 수집 엔트리
* `crawler.log`: 크롤링 실행 로그 (표준 출력/에러 통합)
* `database.py`: Supabase 저장 로직 (최근 0kW 보호 패치 적용됨)
* **`~/solorpower_server/`**: FastAPI 기반의 백엔드 API 서버 경로
* **`~/plant_sync/`**: 발전소 정보 동기화 및 기타 유틸리티
### ⏰ 자동화 스케줄 (Crontab)
서버에서 `crontab -l` 명령어로 확인된 자동화 설정입니다.
* `*/10 * * * *`: 10분마다 데이터 수집 실행 (`main.py`)
* `10 0 * * *`: 매일 0시 10분에 일일 통계 요약 실행 (`daily_summary.py`)
---
## 3. 로컬 프로젝트 구조 (Local)
개발 PC(`d:\dev\etc\SolorPower`)의 구성입니다.
* **`crawler/`**: Python 기반 데이터 수집기 소스 코드
* **`api_server/`**: FastAPI 기반 백엔드 소스 코드
* **`app/`**: React Native (Expo) 모바일 애플리케이션 소스 코드
* **`sql/`**: 데이터베이스 스키마 및 히스토리 관리
* **`docs/`**: 시스템 설계 및 분석 리포트 (현재 이 문서가 포함된 위치)
---
## 4. 데이터 흐름 (Data Flow)
1. **수집:** Crawler(Oracle Cloud) → 각 발전소 사이트 (나스 프록시 경유)
2. **저장:** Crawler → Supabase (PostgreSQL)
3. **알림:** Crawler → Telegram Bot (이상 감지 시 즉시 발송)
4. **제공:** API Server(Oracle Cloud) → Supabase 데이터 가공 후 JSON 제공
5. **표시:** Mobile App (Expo) → API 호출 후 차트 및 현황 표시
---
## 5. 주요 점검 및 대응 가이드
### 발전량이 0으로 표시될 때 (오늘 발생 사례)
1. **서버 접속:** `ssh oracle-fishingmap`이 아닌 `140.245.73.212`로 접속 확인
2. **로그 확인:** `tail -n 20 ~/solorpower_crawler/crawler.log`
3. **코드 패치:** `git pull origin main` (최신 보호 로직 반영 여부 확인)
4. **DB 확인:** Supabase `solar_logs` 테이블에서 해당 시간대 `status` 확인
---
*마지막 업데이트: 2026-05-14 (0kW 오보 보호 패치 및 인프라 정리)*

80
sql/00_init_schema.sql Normal file
View File

@ -0,0 +1,80 @@
-- ==============================================================================
-- Initial Schema Migration Dump
-- Generated: 2026-04-14
-- Description: 현재 Supabase DB에 존재하는 전체 테이블 구조 요약 (의존성 순서)
-- ==============================================================================
-- ------------------------------------------------------------------------------
-- 1. COMPANIES (업체 및 고객사 정보)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.companies (
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- 2. PLANTS (발전소 기본 정보)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.plants (
id TEXT PRIMARY KEY,
company_id BIGINT REFERENCES public.companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT,
capacity DOUBLE PRECISION DEFAULT 100.0,
initial_cumulative_kwh DOUBLE PRECISION DEFAULT 0.0,
constructed_at DATE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON COLUMN public.plants.initial_cumulative_kwh IS '시스템 도입 이전의 누적 발전량 (기초 자산)';
-- ------------------------------------------------------------------------------
-- 3. SOLAR_LOGS (실시간/시간 단위 발전 로그)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.solar_logs (
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE,
current_kw DOUBLE PRECISION,
today_kwh DOUBLE PRECISION,
status TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- 4. DAILY_STATS (일일 발전 통계 요약)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.daily_stats (
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE,
date DATE NOT NULL,
total_generation DOUBLE PRECISION DEFAULT 0.0,
peak_kw DOUBLE PRECISION DEFAULT 0.0,
generation_hours DOUBLE PRECISION DEFAULT 0.0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(plant_id, date) -- 크롤러에서 UPSERT시 사용되는 제약조건
);
COMMENT ON TABLE public.daily_stats IS '일일 발전 통계 요약 테이블';
COMMENT ON COLUMN public.daily_stats.total_generation IS '당일 총 발전량 (kWh)';
COMMENT ON COLUMN public.daily_stats.peak_kw IS '당일 최고 출력 (kW)';
COMMENT ON COLUMN public.daily_stats.generation_hours IS '발전 시간 = 발전량 / 설비용량';
CREATE INDEX IF NOT EXISTS idx_daily_stats_plant_id ON public.daily_stats(plant_id);
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON public.daily_stats(date);
CREATE INDEX IF NOT EXISTS idx_daily_stats_plant_date ON public.daily_stats(plant_id, date);
-- ------------------------------------------------------------------------------
-- 5. MONTHLY_STATS (월간 발전 통계 요약)
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.monthly_stats (
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
plant_id TEXT REFERENCES public.plants(id) ON DELETE CASCADE,
month TEXT NOT NULL,
total_generation DOUBLE PRECISION DEFAULT 0.0,
currnet_last_date TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()),
UNIQUE(plant_id, month) -- 크롤러에서 UPSERT시 사용되는 제약조건
);

View File

@ -0,0 +1,42 @@
# 🗄️ SolarPower Database History & Security Requirements
이 문서는 과거의 파편화된 데이터베이스 업데이트 내역과 보안(RLS) 점검 보고서를 하나로 통합한 가이드입니다. **Supabase 관리 시 이 문서를 기준으로 보안 정책과 과거 변경 이력을 참고하세요.**
## 1. 📋 테이블 구조 핵심 변경 이력
| 일자 | 내용 | 비고 |
|---|---|---|
| **2026-01-23** | `daily_stats` (일별 통계 요약 테이블) 신설 및 인덱스 추가 | `plant_id`, `date` 복합 인덱스 및 Unique 제약조건 적용 |
| **2026-01-23** | `plants` 테이블 `initial_cumulative_kwh` 컬럼 추가 | 시스템 도입 이전의 누적 발전량(자산) 추적 목적 |
| **2026-01-30** | RLS 보안 정책 대규모 적용 | `plants`, `solar_logs`, `daily_stats`, `monthly_stats` 테이블 전체 |
| **2026-04-14** | `plants` 테이블 `alerts_enabled` 컬럼 추가 (진행) | 대시보드에서 앱 알림을 토글링할 수 있도록 불리언 제어 컬럼 추가 |
---
## 2. 🛡️ 보안 통제 정책 (Row Level Security)
데이터베이스의 주요 4개 테이블(`plants`, `solar_logs`, `daily_stats`, `monthly_stats`)에는 치명적인 무단 조작을 방지하기 위해 엄격한 **RLS(Row Level Security)** 가 적용되어 있습니다.
### 사용자 등급별 권한
- **API 서버 및 크롤러 (service_role)**: 모든 데이터에 대한 자유로운 읽기/쓰기(INSERT, UPDATE) 권한 보유(`.env` 파일 내 `SUPABASE_KEY``service_role` 토큰이어야 함).
- **일반 익명 사용자 (anon 권한 - App/Dashboard)**: 조회(SELECT)만 가능하며 데이터 수정이나 식별 불가능한 데이터 조작은 원천 차단됩니다.
```sql
-- RLS 활성화 예시
ALTER TABLE plants ENABLE ROW LEVEL SECURITY;
-- 익명 사용자는 조회만 가능
CREATE POLICY "Allow anonymous read access" ON plants FOR SELECT TO anon USING (true);
-- 서비스 로직은 모든 권한 허용
CREATE POLICY "Allow service_role full access" ON plants FOR ALL TO service_role USING (true) WITH CHECK (true);
```
---
## 3. 🚨 데이터베이스 운영 환경 권장 사항
1. **API 키 관리 주의점**:
- 프론트엔드 모바일 앱/대시보드 소스코드에 삽입되는 Supabase Key는 반드시 `anon` (읽기 전용 퍼블릭) 키여야 합니다.
- 크롤러(`.env`)와 백엔드 API 서버(`.env`)는 `service_role` (마스터) 키를 사용 중입니다. 이 키가 외부 저장소나 클라이언트에 노출되지 않도록 각별히 유의해야 합니다.
2. **IP 화이트리스트 검토**:
- 오라클 클라우드(API 서버) 및 NAS 크롤러 서버에서만 Supabase DB에 직접 Write 할 수 있도록 DB Network Restrictions 설정에서 IP 기반 제한을 걸어두는 것을 강력히 권장합니다.

View File

@ -0,0 +1,10 @@
-- Migration: Add alerts_enabled to plants table
-- Description: 발전소 대시보드(모바일/웹)에서 이상 알림을 끄거나 켤 수 있도록 상태를 저장하는 컬럼 추가
-- Created At: 2026-04-14
-- 1. plants 테이블에 alerts_enabled 컬럼 추가 (기본값: true)
ALTER TABLE public.plants
ADD COLUMN IF NOT EXISTS alerts_enabled BOOLEAN DEFAULT TRUE;
-- 2. 컬럼 역할에 대한 설명(주석) 추가
COMMENT ON COLUMN public.plants.alerts_enabled IS '발전소 이상 감지 알림 활성화 여부 (true: 알림 켬, false: 알림 끔)';

12
tools/mapping.json Normal file
View File

@ -0,0 +1,12 @@
{
"1호기": "nrems-01",
"1호기.1": "nrems-01",
"2호기": "nrems-02",
"3호기": "nrems-03",
"4호기": "nrems-04",
"5호기": "nrems-05",
"6호기": "nrems-06",
"8호기": "nrems-08",
"9호기": "nrems-09",
"10호기": "nrems-10"
}

View File

@ -0,0 +1,406 @@
"""
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()

326
tools/solar_converter_v2.py Normal file
View File

@ -0,0 +1,326 @@
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()

591
tools/solar_gui_modern.py Normal file
View File

@ -0,0 +1,591 @@
"""
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()