chore: merge subprojects into monorepo layout
This commit is contained in:
parent
072d038410
commit
1724a80426
129
AGENTS.md
Normal file
129
AGENTS.md
Normal 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
335
README.md
Normal 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
33
app/server.log
Normal 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"
|
||||
64
crawler/tools/_check_alert.py
Normal file
64
crawler/tools/_check_alert.py
Normal 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
48
docs/check.md
Normal 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) 등으로의 단계적 기능 고도화를 계획하고 있습니다. 장기적인 파트너십을 고려하여 제안해 주시기 바랍니다.
|
||||
315
docs/platform_analysis_report.md
Normal file
315
docs/platform_analysis_report.md
Normal 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 차단 우회**는 서비스 신뢰도의 기반이므로, 개발 착수 전 이 두 가지 아키텍처를 먼저 확정한 후 나머지 기능을 쌓는 순서로 진행하기를 강력히 권장합니다.
|
||||
75
docs/system_infrastructure_guide.md
Normal file
75
docs/system_infrastructure_guide.md
Normal 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
80
sql/00_init_schema.sql
Normal 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시 사용되는 제약조건
|
||||
);
|
||||
42
sql/DATABASE_HISTORY_AND_SECURITY.md
Normal file
42
sql/DATABASE_HISTORY_AND_SECURITY.md
Normal 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 기반 제한을 걸어두는 것을 강력히 권장합니다.
|
||||
10
sql/migration_add_alerts_enabled.sql
Normal file
10
sql/migration_add_alerts_enabled.sql
Normal 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
12
tools/mapping.json
Normal 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"
|
||||
}
|
||||
406
tools/solar_converter_gui.py
Normal file
406
tools/solar_converter_gui.py
Normal 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
326
tools/solar_converter_v2.py
Normal 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
591
tools/solar_gui_modern.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user