Compare commits

..

No commits in common. "1724a804262b5d1474cf4475495c25d07e49e178" and "d8a3efab065be0112603d857d5d52b1dcce15fd3" have entirely different histories.

101 changed files with 28 additions and 14554 deletions

59
.gitignore vendored
View File

@ -1,39 +1,34 @@
# Dependencies
node_modules/
.expo/
dist/
build/
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.venv/
venv/
env/
# Database & Credentials
*.db
*.db-journal
# Environment
.env
.env.*
secrets.json
.env.local
.env.*.local
# IDEs and Editors
.vscode/
# Virtual Environment
venv/
.venv/
env/
ENV/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# IDE
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vscode/
*.swp
*.swo
# OS files
# OS
.DS_Store
Thumbs.db
# Temporary directories
temp_git/
scratch/
# Distribution / Build
dist/
build/
*.egg-info/
# Logs
*.log

129
AGENTS.md
View File

@ -1,129 +0,0 @@
# 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
View File

@ -1,335 +0,0 @@
# ☀️ 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
- 대시보드 필드명 매핑 수정.

34
api_server/.gitignore vendored
View File

@ -1,34 +0,0 @@
# Environment
.env
.env.local
.env.*.local
# Virtual Environment
venv/
.venv/
env/
ENV/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Distribution / Build
dist/
build/
*.egg-info/
# Logs
*.log

37
app/.gitignore vendored
View File

@ -1,37 +0,0 @@
# Expo
.expo/
*.jks
*.p12
*.key
*.keystore
*.db
# Node modules
node_modules/
# npm
npm-debug.log
package-lock.json
yarn-error.log
# Mac
.DS_Store
# Android
android/
!android/build.gradle
!android/settings.gradle
!android/app/build.gradle
!android/app/src/main/AndroidManifest.xml
# iOS
ios/
!ios/Podfile
# Web
web-build/
dist/
# Env
.env
.env.local

View File

@ -1,579 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
StyleSheet,
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
useWindowDimensions,
RefreshControl,
Switch,
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import PlantDetailScreen from './screens/PlantDetailScreen';
import ComparisonScreen from './screens/ComparisonScreen';
const API_URL = 'https://solorpower.dadot.net/plants/1';
const Stack = createNativeStackNavigator();
// 메인 대시보드 화면
function DashboardScreen({ navigation }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
const { width } = useWindowDimensions();
const isPC = width >= 768;
const fetchData = useCallback(async () => {
try {
setError(null);
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleToggleAlert = async (plant, newValue) => {
try {
setData((prev) => {
if (!prev) return prev;
const newData = { ...prev };
newData.data = newData.data.map(p =>
p.id === plant.id ? { ...p, alerts_enabled: newValue } : p
);
return newData;
});
const response = await fetch(`https://solorpower.dadot.net/plants/${plant.company_id || 1}/${plant.id}/alerts`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ alerts_enabled: newValue }),
});
if (!response.ok) {
throw new Error('Failed to update alert settings');
}
} catch (err) {
console.error(err);
setData((prev) => {
if (!prev) return prev;
const newData = { ...prev };
newData.data = newData.data.map(p =>
p.id === plant.id ? { ...p, alerts_enabled: !newValue } : p
);
return newData;
});
alert('알림 설정 변경에 실패했습니다.');
}
};
const onRefresh = useCallback(() => {
setRefreshing(true);
fetchData();
}, [fetchData]);
// 발전소 상태 및 아이콘 결정
const getStatusInfo = (plant) => {
const latestLog = plant.latest_log;
const currentKw = latestLog?.current_kw || 0;
const status = latestLog?.status;
if (status === '오류' || status === 'error') {
return { icon: '🔴', label: '오류', color: '#EF4444' };
}
// 야간 시간대 확인 (18시 ~ 06시)
const hour = new Date().getHours();
const isNight = hour >= 18 || hour < 6;
if (currentKw === 0) {
if (isNight) {
return { icon: '🌙', label: '야간', color: '#4B5563' };
}
return { icon: '💤', label: '대기', color: '#6B7280' };
}
return { icon: '🟢', label: status || '정상', color: '#10B981' };
};
// 요약 데이터 계산
const getSummary = () => {
if (!data?.data || data.data.length === 0) {
return { totalOutput: 0, operatingRate: 0, totalPlants: 0, activePlants: 0 };
}
const plants = data.data;
const totalPlants = plants.length;
let totalOutput = 0;
let activePlants = 0;
plants.forEach((plant) => {
const currentKw = plant.latest_log?.current_kw || 0;
totalOutput += currentKw;
if (currentKw > 0) {
activePlants++;
}
});
const operatingRate = totalPlants > 0 ? (activePlants / totalPlants) * 100 : 0;
return { totalOutput, operatingRate, totalPlants, activePlants };
};
// 로딩 상태
if (loading) {
return (
<View style={styles.centerContainer}>
<StatusBar style="auto" />
<ActivityIndicator size="large" color="#3B82F6" />
<Text style={styles.loadingText}>데이터를 불러오는 ...</Text>
</View>
);
}
// 에러 상태
if (error) {
return (
<View style={styles.centerContainer}>
<StatusBar style="auto" />
<Text style={styles.errorIcon}></Text>
<Text style={styles.errorText}>오류가 발생했습니다</Text>
<Text style={styles.errorDetail}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={fetchData}>
<Text style={styles.retryButtonText}>다시 시도</Text>
</TouchableOpacity>
</View>
);
}
const summary = getSummary();
// 발전소 이름의 숫자 기준으로 정렬 (예: "태양과바람 1호기" -> 1)
const plants = [...(data?.data || [])].sort((a, b) => {
const getNumber = (str) => {
// 문자열에서 첫 번째 연속된 숫자를 추출
const match = str && str.match(/(\d+)/);
return match ? parseInt(match[1], 10) : Infinity;
};
const numA = getNumber(a.name);
const numB = getNumber(b.name);
if (numA !== numB) return numA - numB;
return (a.name || '').localeCompare(b.name || '');
});
// 발전소 카드 클릭 핸들러
const handlePlantPress = (plant) => {
navigation.navigate('PlantDetail', { plant });
};
return (
<View style={styles.container}>
<StatusBar style="light" />
{/* 상단 헤더 */}
<View style={styles.header}>
<View style={styles.headerTop}>
<Text style={styles.headerTitle}> 태양광 발전 현황</Text>
<TouchableOpacity
style={styles.compareButton}
onPress={() => navigation.navigate('Comparison')}
>
<Text style={styles.compareButtonText}>📊 전체 비교</Text>
</TouchableOpacity>
</View>
<Text style={styles.headerSubtitle}>실시간 모니터링 대시보드</Text>
</View>
{/* 요약 카드 */}
<View style={[styles.summaryContainer, isPC && styles.summaryContainerPC]}>
<View style={[styles.summaryCard, styles.summaryCardPrimary]}>
<Text style={styles.summaryLabel}> 출력</Text>
<Text style={styles.summaryValue}>{summary.totalOutput.toFixed(1)}</Text>
<Text style={styles.summaryUnit}>kW</Text>
</View>
<View style={[styles.summaryCard, styles.summaryCardSecondary]}>
<Text style={styles.summaryLabel}>가동률</Text>
<Text style={styles.summaryValue}>{summary.operatingRate.toFixed(0)}</Text>
<Text style={styles.summaryUnit}>%</Text>
</View>
<View style={[styles.summaryCard, styles.summaryCardTertiary]}>
<Text style={styles.summaryLabel}>가동 현황</Text>
<Text style={styles.summaryValue}>{summary.activePlants}/{summary.totalPlants}</Text>
<Text style={styles.summaryUnit}>발전소</Text>
</View>
</View>
{/* 발전소 목록 */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.cardContainer,
isPC && styles.cardContainerPC,
]}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{plants.map((plant, index) => {
const statusInfo = getStatusInfo(plant);
const latestLog = plant.latest_log;
const currentKw = latestLog?.current_kw || 0;
const dailyKwh = latestLog?.today_kwh || 0;
return (
<TouchableOpacity
key={plant.id || index}
style={[styles.card, isPC && styles.cardPC]}
onPress={() => handlePlantPress(plant)}
activeOpacity={0.7}
>
<View style={styles.cardHeader}>
<Text style={styles.statusIcon}>{statusInfo.icon}</Text>
<View style={styles.cardTitleContainer}>
<Text style={styles.cardTitle}>{plant.name}</Text>
<Text style={[styles.statusLabel, { color: statusInfo.color }]}>
{statusInfo.label}
</Text>
</View>
<View style={styles.alertToggleContainer}>
<Text style={styles.alertToggleLabel}>
{plant.alerts_enabled !== false ? '알림 ON' : '알림 OFF'}
</Text>
<Switch
value={plant.alerts_enabled !== false}
onValueChange={(value) => handleToggleAlert(plant, value)}
trackColor={{ false: '#D1D5DB', true: '#3B82F6' }}
thumbColor={'#FFFFFF'}
/>
</View>
</View>
<View style={styles.cardBody}>
<View style={styles.dataRow}>
<Text style={styles.dataLabel}>현재 출력</Text>
<Text style={styles.dataValuePrimary}>{currentKw.toFixed(1)} kW</Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataLabel}>금일 발전</Text>
<Text style={styles.dataValue}>{dailyKwh.toFixed(1)} kWh</Text>
</View>
{plant.capacity && (
<View style={styles.dataRow}>
<Text style={styles.dataLabel}>설비 용량</Text>
<Text style={styles.dataValue}>{plant.capacity} kW</Text>
</View>
)}
</View>
{/* 진행률 바 */}
{plant.capacity && (
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{
width: `${Math.min((currentKw / plant.capacity) * 100, 100)}%`,
backgroundColor: statusInfo.color,
},
]}
/>
</View>
<Text style={styles.progressText}>
{((currentKw / plant.capacity) * 100).toFixed(0)}%
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
}
// 메인 앱
export default function App() {
return (
<SafeAreaProvider>
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="Dashboard" component={DashboardScreen} />
<Stack.Screen name="Comparison" component={ComparisonScreen} />
<Stack.Screen name="PlantDetail" component={PlantDetailScreen} />
</Stack.Navigator>
</NavigationContainer>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F3F4F6',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F3F4F6',
padding: 20,
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#6B7280',
},
errorIcon: {
fontSize: 48,
marginBottom: 16,
},
errorText: {
fontSize: 18,
fontWeight: '600',
color: '#1F2937',
marginBottom: 8,
},
errorDetail: {
fontSize: 14,
color: '#6B7280',
marginBottom: 24,
textAlign: 'center',
},
retryButton: {
backgroundColor: '#3B82F6',
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 8,
},
retryButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
// Header
header: {
backgroundColor: '#1E40AF',
paddingTop: 60,
paddingBottom: 24,
paddingHorizontal: 20,
},
headerTop: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#FFFFFF',
marginBottom: 4,
},
headerSubtitle: {
fontSize: 14,
color: '#93C5FD',
},
// Summary
summaryContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 16,
gap: 12,
},
summaryContainerPC: {
maxWidth: 800,
alignSelf: 'center',
width: '100%',
},
summaryCard: {
flex: 1,
borderRadius: 12,
padding: 16,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
summaryCardPrimary: {
backgroundColor: '#3B82F6',
},
summaryCardSecondary: {
backgroundColor: '#10B981',
},
summaryCardTertiary: {
backgroundColor: '#8B5CF6',
},
summaryLabel: {
fontSize: 12,
color: 'rgba(255,255,255,0.8)',
marginBottom: 4,
},
summaryValue: {
fontSize: 28,
fontWeight: 'bold',
color: '#FFFFFF',
},
summaryUnit: {
fontSize: 12,
color: 'rgba(255,255,255,0.8)',
marginTop: 2,
},
// Cards
scrollView: {
flex: 1,
},
cardContainer: {
padding: 16,
gap: 12,
},
cardContainerPC: {
flexDirection: 'row',
flexWrap: 'wrap',
maxWidth: 1200,
alignSelf: 'center',
width: '100%',
gap: 16,
},
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
cardPC: {
width: 'calc(33.333% - 11px)',
minWidth: 280,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
statusIcon: {
fontSize: 32,
marginRight: 12,
},
cardTitleContainer: {
flex: 1,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1F2937',
marginBottom: 2,
},
statusLabel: {
fontSize: 12,
fontWeight: '600',
},
arrowIcon: {
fontSize: 24,
color: '#9CA3AF',
fontWeight: '300',
},
alertToggleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
alertToggleLabel: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
},
cardBody: {
gap: 8,
},
dataRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dataLabel: {
fontSize: 14,
color: '#6B7280',
},
dataValue: {
fontSize: 14,
fontWeight: '500',
color: '#374151',
},
dataValuePrimary: {
fontSize: 20,
fontWeight: '700',
color: '#3B82F6',
},
// Progress Bar
progressContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
gap: 8,
},
progressBar: {
flex: 1,
height: 8,
backgroundColor: '#E5E7EB',
borderRadius: 4,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 4,
},
progressText: {
fontSize: 12,
fontWeight: '600',
color: '#6B7280',
width: 40,
textAlign: 'right',
},
compareButton: {
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.4)',
marginLeft: 12,
},
compareButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
});

View File

@ -1,26 +0,0 @@
{
"expo": {
"name": "태양광 발전 현황",
"slug": "solorpower-dashboard",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"backgroundColor": "#1E40AF"
},
"ios": {
"supportsTablet": true
},
"android": {
"package": "com.solorpower.dashboard",
"adaptiveIcon": {
"backgroundColor": "#1E40AF"
}
},
"web": {
"bundler": "metro",
"output": "single"
}
}
}

View File

@ -1,6 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

View File

@ -1,325 +0,0 @@
/**
* UploadModal.js - 과거 발전 데이터 엑셀 업로드 모달
*/
import React, { useState } from 'react';
// Force Update Check
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
const API_URL = 'https://solorpower.dadot.net';
export default function UploadModal({ visible, onClose, plantId, onUploadSuccess }) {
const [uploading, setUploading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [uploadType, setUploadType] = useState('daily'); // 'daily' | 'monthly'
// 파일 선택
const pickDocument = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
],
copyToCacheDirectory: true,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setSelectedFile(result.assets[0]);
}
} catch (error) {
Alert.alert('오류', '파일 선택 중 오류가 발생했습니다.');
}
};
// 업로드 실행
const handleUpload = async () => {
if (!selectedFile) {
Alert.alert('알림', '먼저 파일을 선택해주세요.');
return;
}
if (!plantId) {
Alert.alert('알림', '발전소를 선택해주세요.');
return;
}
setUploading(true);
try {
const formData = new FormData();
// 파일 추가
formData.append('file', {
uri: selectedFile.uri,
name: selectedFile.name,
type: selectedFile.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
// uploadType에 따라 엔드포인트 분기
const endpoint = uploadType === 'daily'
? `/plants/${plantId}/upload`
: `/plants/${plantId}/upload/monthly`;
console.log(`🚀 Uploading ${uploadType} data`);
console.log(" URL:", `${API_URL}${endpoint}`);
const response = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
});
const result = await response.json();
if (response.ok) {
Alert.alert('성공', result.message || '업로드가 완료되었습니다.');
setSelectedFile(null);
onUploadSuccess?.();
onClose();
} else {
Alert.alert('오류', result.detail || '업로드에 실패했습니다.');
}
} catch (error) {
Alert.alert('오류', `업로드 중 오류가 발생했습니다: ${error.message}`);
} finally {
setUploading(false);
}
};
// 모달 닫기
const handleClose = () => {
setSelectedFile(null);
setUploadType('daily');
onClose();
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<View style={styles.modalContainer}>
{/* 헤더 */}
<View style={styles.header}>
<Text style={styles.title}>📂 과거 데이터 업로드</Text>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
</View>
{/* 타입 선택 탭 */}
<View style={{ flexDirection: 'row', marginBottom: 16, backgroundColor: '#F3F4F6', borderRadius: 8, padding: 4 }}>
<TouchableOpacity
style={{
flex: 1,
paddingVertical: 8,
alignItems: 'center',
borderRadius: 6,
backgroundColor: uploadType === 'daily' ? '#FFFFFF' : 'transparent',
shadowColor: uploadType === 'daily' ? '#000' : 'transparent',
shadowOpacity: uploadType === 'daily' ? 0.1 : 0,
shadowRadius: 2,
elevation: uploadType === 'daily' ? 2 : 0,
}}
onPress={() => setUploadType('daily')}
>
<Text style={{ fontWeight: uploadType === 'daily' ? '600' : '400', color: '#374151' }}>일간 (Daily)</Text>
</TouchableOpacity>
<TouchableOpacity
style={{
flex: 1,
paddingVertical: 8,
alignItems: 'center',
borderRadius: 6,
backgroundColor: uploadType === 'monthly' ? '#FFFFFF' : 'transparent',
shadowColor: uploadType === 'monthly' ? '#000' : 'transparent',
shadowOpacity: uploadType === 'monthly' ? 0.1 : 0,
shadowRadius: 2,
elevation: uploadType === 'monthly' ? 2 : 0,
}}
onPress={() => setUploadType('monthly')}
>
<Text style={{ fontWeight: uploadType === 'monthly' ? '600' : '400', color: '#374151' }}>월간 (Monthly)</Text>
</TouchableOpacity>
</View>
{/* 안내 */}
<Text style={styles.description}>
{uploadType === 'daily'
? "필수 컬럼: date (날짜), generation (발전량) 또는 year, month, day, kwh"
: "필수 컬럼: year (연도), month (월), kwh (발전량)\n* 합계/평균 행 자동 제외"
}
</Text>
{/* 발전소 ID 표시 */}
<View style={styles.infoBox}>
<Text style={styles.infoLabel}>발전소 ID:</Text>
<Text style={styles.infoValue}>{plantId || '선택 안됨'}</Text>
</View>
{/* 파일 선택 */}
<TouchableOpacity style={styles.fileButton} onPress={pickDocument}>
<Text style={styles.fileButtonText}>
{selectedFile ? '📄 ' + selectedFile.name : '📁 엑셀 파일 선택 (.xlsx)'}
</Text>
</TouchableOpacity>
{/* 선택된 파일 정보 */}
{selectedFile && (
<View style={styles.fileInfo}>
<Text style={styles.fileInfoText}>
크기: {(selectedFile.size / 1024).toFixed(1)} KB
</Text>
</View>
)}
{/* 업로드 버튼 */}
<TouchableOpacity
style={[
styles.uploadButton,
(!selectedFile || uploading) && styles.uploadButtonDisabled,
]}
onPress={handleUpload}
disabled={!selectedFile || uploading}
>
{uploading ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.uploadButtonText}>
{uploadType === 'daily' ? '일간 데이터 업로드' : '월간 데이터 업로드'}
</Text>
)}
</TouchableOpacity>
{/* 취소 버튼 */}
<TouchableOpacity style={styles.cancelButton} onPress={handleClose}>
<Text style={styles.cancelButtonText}>취소</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 24,
width: '100%',
maxWidth: 400,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 16,
elevation: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#1F2937',
},
closeButton: {
padding: 4,
},
closeButtonText: {
fontSize: 20,
color: '#6B7280',
},
description: {
fontSize: 14,
color: '#6B7280',
marginBottom: 16,
lineHeight: 20,
},
infoBox: {
flexDirection: 'row',
backgroundColor: '#F3F4F6',
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
infoLabel: {
fontSize: 14,
color: '#6B7280',
marginRight: 8,
},
infoValue: {
fontSize: 14,
fontWeight: '600',
color: '#1F2937',
},
fileButton: {
backgroundColor: '#E5E7EB',
padding: 16,
borderRadius: 8,
alignItems: 'center',
marginBottom: 8,
borderWidth: 2,
borderColor: '#D1D5DB',
borderStyle: 'dashed',
},
fileButtonText: {
fontSize: 16,
color: '#374151',
},
fileInfo: {
alignItems: 'center',
marginBottom: 16,
},
fileInfoText: {
fontSize: 12,
color: '#6B7280',
},
uploadButton: {
backgroundColor: '#3B82F6',
padding: 16,
borderRadius: 8,
alignItems: 'center',
marginBottom: 8,
},
uploadButtonDisabled: {
backgroundColor: '#9CA3AF',
},
uploadButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
cancelButton: {
padding: 12,
alignItems: 'center',
},
cancelButtonText: {
fontSize: 14,
color: '#6B7280',
},
});

View File

@ -1,25 +0,0 @@
{
"cli": {
"version": ">= 3.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}

View File

@ -1,32 +0,0 @@
{
"name": "solorpower-dashboard",
"version": "1.0.0",
"main": "expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.10.1",
"expo": "~52.0.0",
"expo-document-picker": "~13.0.3",
"expo-linear-gradient": "~14.0.2",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.5",
"react-native-gifted-charts": "^1.4.70",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.20.0",
"react-native-svg": "^15.15.1",
"react-native-web": "~0.19.13"
},
"devDependencies": {
"@babel/core": "^7.25.2"
},
"private": true
}

View File

@ -9,7 +9,7 @@ from supabase import Client
from typing import List
from app.core.database import get_db
from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase, PlantAlertUpdateRequest
from app.schemas.plant import PlantsListResponse, PlantWithLatestLog, SolarLogBase
router = APIRouter(
prefix="/plants",
@ -138,40 +138,3 @@ async def get_plant_detail(
status_code=500,
detail=f"데이터베이스 조회 중 오류가 발생했습니다: {str(e)}"
)
@router.patch("/{company_id}/{plant_id}/alerts")
async def update_plant_alerts(
company_id: int,
plant_id: str,
alert_update: PlantAlertUpdateRequest,
db: Client = Depends(get_db)
) -> dict:
"""
특정 발전소의 알림 활성화 상태를 변경합니다.
"""
try:
response = db.table("plants") \
.update({"alerts_enabled": alert_update.alerts_enabled}) \
.eq("id", plant_id) \
.eq("company_id", company_id) \
.execute()
if not response.data:
raise HTTPException(
status_code=404,
detail="발전소를 찾을 수 없거나 업데이트에 실패했습니다."
)
return {
"status": "success",
"message": "알림 설정이 변경되었습니다.",
"data": response.data[0]
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"알림 설정 변경 중 오류가 발생했습니다: {str(e)}"
)

View File

@ -17,13 +17,8 @@ class PlantBase(BaseModel):
name: str
capacity: Optional[float] = Field(None, description="발전 용량 (kW)")
location: Optional[str] = Field(None, description="발전소 위치")
alerts_enabled: Optional[bool] = Field(True, description="이상 알림 활성화 여부")
created_at: Optional[datetime] = None
class PlantAlertUpdateRequest(BaseModel):
alerts_enabled: bool
class SolarLogBase(BaseModel):
"""발전 로그 기본 정보 스키마"""

View File

@ -1,432 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
StyleSheet,
View,
Text,
TouchableOpacity,
ActivityIndicator,
ScrollView,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { BarChart } from 'react-native-gifted-charts';
const API_BASE_URL = 'https://solorpower.dadot.net';
export default function ComparisonScreen({ navigation }) {
const [period, setPeriod] = useState('day');
const [statsData, setStatsData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [currentDate, setCurrentDate] = useState(new Date());
const { width } = useWindowDimensions();
useEffect(() => {
setCurrentDate(new Date());
}, [period]);
const moveDate = (direction) => {
const newDate = new Date(currentDate);
if (period === 'day') {
newDate.setDate(newDate.getDate() + direction);
} else if (period === 'month') {
newDate.setMonth(newDate.getMonth() + direction);
} else if (period === 'year') {
newDate.setFullYear(newDate.getFullYear() + direction);
}
setCurrentDate(newDate);
};
const formatDateDisplay = () => {
const y = currentDate.getFullYear();
const m = currentDate.getMonth() + 1;
const d = currentDate.getDate();
if (period === 'day') return `${y}${m}${d}`;
if (period === 'month') return `${y}${m}`;
if (period === 'year') return `${y}`;
return '';
};
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const y = currentDate.getFullYear();
const m = currentDate.getMonth() + 1;
const d = currentDate.getDate();
const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
let query = `period=${period}`;
if (period === 'day') {
query += `&date=${dateStr}`;
} else if (period === 'month') {
query += `&year=${y}&month=${m}`;
} else if (period === 'year') {
query += `&year=${y}`;
}
const response = await fetch(`${API_BASE_URL}/plants/stats/comparison?${query}`);
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const result = await response.json();
setStatsData(result.data || []);
} catch (err) {
setError(err.message);
setStatsData([]);
} finally {
setLoading(false);
}
}, [period, currentDate]);
useEffect(() => {
fetchStats();
}, [fetchStats]);
// Chart Data Preparation
const chartData = statsData.map(item => {
let label = item.plant_name;
const match = label.match(/(\d+호기)/);
if (match) {
label = match[1];
} else {
label = label.replace(/태양과바람|발전소/g, '').trim();
}
return {
value: item.generation,
label: label,
frontColor: '#3B82F6',
topLabelComponent: () => (
<Text style={{ color: '#6B7280', fontSize: 10, marginBottom: 4 }}>
{item.generation.toFixed(0)}
</Text>
),
};
});
const renderTabs = () => (
<View style={styles.tabContainer}>
{['day', 'month', 'year'].map((p) => (
<TouchableOpacity
key={p}
style={[styles.tab, period === p && styles.tabActive]}
onPress={() => setPeriod(p)}
>
<Text style={[styles.tabText, period === p && styles.tabTextActive]}>
{p === 'day' ? '일간' : p === 'month' ? '월간' : '연간'}
</Text>
</TouchableOpacity>
))}
</View>
);
const renderDateControl = () => (
<View style={styles.dateControl}>
<TouchableOpacity onPress={() => moveDate(-1)} style={styles.arrowButton}>
<Text style={styles.arrowText}></Text>
</TouchableOpacity>
<Text style={styles.dateText}>{formatDateDisplay()}</Text>
<TouchableOpacity onPress={() => moveDate(1)} style={styles.arrowButton}>
<Text style={styles.arrowText}></Text>
</TouchableOpacity>
</View>
);
const renderTable = () => (
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.columnHeader, { flex: 2 }]}>발전소</Text>
<Text style={[styles.columnHeader, { flex: 1.5 }]}>발전량</Text>
<Text style={[styles.columnHeader, { flex: 1.5 }]}>용량</Text>
<Text style={[styles.columnHeader, { flex: 1.5 }]}>시간</Text>
</View>
{statsData.map((item, index) => (
<View key={item.plant_id} style={[styles.tableRow, index % 2 === 1 && styles.tableRowAlt]}>
<Text style={[styles.cell, { flex: 2, textAlign: 'left' }]}>{item.plant_name}</Text>
<Text style={[styles.cell, { flex: 1.5 }]}>{item.generation.toFixed(1)}</Text>
<Text style={[styles.cell, { flex: 1.5, color: '#9CA3AF' }]}>{item.capacity}</Text>
<Text style={[styles.cell, { flex: 1.5, fontWeight: 'bold', color: '#10B981' }]}>
{item.generation_hours.toFixed(2)}
</Text>
</View>
))}
{statsData.length === 0 && (
<View style={styles.emptyRow}>
<Text style={styles.emptyText}>데이터가 없습니다</Text>
</View>
)}
</View>
);
const chartWidth = width - 40;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
<Text style={styles.backButtonText}> 뒤로</Text>
</TouchableOpacity>
<Text style={styles.headerTitle}>전체 발전소 비교</Text>
<View style={{ width: 60 }} />
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{renderDateControl()}
{renderTabs()}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3B82F6" />
<Text style={styles.loadingText}>데이터 불러오는 ...</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>데이터 조회 실패</Text>
<Text style={styles.errorDetail}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={fetchStats}>
<Text style={styles.retryButtonText}>다시 시도</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.chartSection}>
<Text style={styles.sectionTitle}>📊 발전량 비교 (kWh)</Text>
<View style={styles.chartContainer}>
{statsData.length > 0 ? (
<BarChart
data={chartData}
width={chartWidth}
height={200}
barWidth={20}
spacing={15}
barBorderRadius={4}
frontColor="#3B82F6"
yAxisThickness={0}
xAxisThickness={1}
yAxisTextStyle={{ color: '#9CA3AF', fontSize: 10 }}
xAxisLabelTextStyle={{ color: '#6B7280', fontSize: 10 }}
hideRules
showValuesAsTopLabel
/>
) : (
<Text style={styles.noDataText}>표시할 데이터가 없습니다.</Text>
)}
</View>
</View>
<View style={styles.tableSection}>
<View style={styles.tableHeaderRow}>
<Text style={styles.sectionTitle}>📋 상세 현황</Text>
<Text style={styles.unitText}>시간 = 발전량 / 설비용량 {period === 'year' ? '/ 365' : ''}</Text>
</View>
{renderTable()}
</View>
</>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F3F4F6',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#1E40AF',
padding: 16,
},
backButton: {
padding: 8,
},
backButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '500',
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#FFFFFF',
},
content: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 40,
},
dateControl: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
gap: 16,
},
arrowButton: {
padding: 8,
},
arrowText: {
fontSize: 20,
color: '#4B5563',
},
dateText: {
fontSize: 18,
fontWeight: 'bold',
color: '#1F2937',
},
tabContainer: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 4,
marginBottom: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderRadius: 8,
},
tabActive: {
backgroundColor: '#3B82F6',
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#6B7280',
},
tabTextActive: {
color: '#FFFFFF',
},
loadingContainer: {
height: 300,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
color: '#6B7280',
},
errorContainer: {
height: 300,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: '#EF4444',
marginBottom: 8,
},
errorDetail: {
fontSize: 12,
color: '#6B7280',
marginBottom: 16,
},
retryButton: {
backgroundColor: '#3B82F6',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: '#FFFFFF',
},
chartSection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1F2937',
marginBottom: 12,
},
chartContainer: {
alignItems: 'center',
paddingVertical: 10,
},
noDataText: {
color: '#9CA3AF',
marginVertical: 20,
},
tableSection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
tableHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
unitText: {
fontSize: 11,
color: '#6B7280',
},
table: {
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 8,
overflow: 'hidden',
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#F3F4F6',
paddingVertical: 10,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
columnHeader: {
fontSize: 12,
fontWeight: 'bold',
color: '#4B5563',
textAlign: 'center',
},
tableRow: {
flexDirection: 'row',
paddingVertical: 12,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
tableRowAlt: {
backgroundColor: '#F9FAFB',
},
cell: {
fontSize: 12,
color: '#1F2937',
textAlign: 'center',
},
emptyRow: {
padding: 20,
alignItems: 'center',
},
emptyText: {
color: '#9CA3AF',
fontSize: 14,
},
});

View File

@ -1,791 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
StyleSheet,
View,
Text,
TouchableOpacity,
ActivityIndicator,
ScrollView,
Alert,
useWindowDimensions,
Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { BarChart } from 'react-native-gifted-charts';
import * as DocumentPicker from 'expo-document-picker';
const API_BASE_URL = 'https://solorpower.dadot.net';
export default function PlantDetailScreen({ route, navigation }) {
const { plant } = route.params;
const [period, setPeriod] = useState('today');
const [chartData, setChartData] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(null);
const [todayData, setTodayData] = useState(null);
const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, label: '', value: 0 });
const { width } = useWindowDimensions();
const [currentDate, setCurrentDate] = useState(new Date());
// 탭 변경 시 날짜 초기화
useEffect(() => {
setCurrentDate(new Date());
}, [period]);
// 날짜 이동
const moveDate = (direction) => {
const newDate = new Date(currentDate);
if (period === 'today') {
newDate.setDate(newDate.getDate() + direction);
} else if (period === 'day') {
// 일간 탭: '월' 단위 이동
newDate.setMonth(newDate.getMonth() + direction);
} else if (period === 'month') {
// 월간 탭: '년' 단위 이동
newDate.setFullYear(newDate.getFullYear() + direction);
} else if (period === 'year') {
// 연간 탭: '년' 단위 이동 (일단 1년씩)
newDate.setFullYear(newDate.getFullYear() + direction);
}
setCurrentDate(newDate);
};
// 통계 데이터 조회
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const y = currentDate.getFullYear();
const m = currentDate.getMonth() + 1;
const d = currentDate.getDate();
const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
if (period === 'today') {
// 오늘 실시간 데이터는 이미 전달받은 plant.latest_log 사용 (상단 카드용)
// 만약 과거 날짜를 선택했다면 상단 카드는 숨기거나 해당 날짜의 요약으로 대체해야 함.
// 여기선 '오늘'인 경우에만 상단 카드를 보여주는 식으로 처리 가능.
// 서버에서 시간별 데이터 조회
const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats/today?date=${dateStr}`);
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const result = await response.json();
const hourlyData = result.data || [];
// 24시간 데이터 포맷팅
const formattedData = [];
for (let i = 0; i <= 24; i++) {
if (i === 24) {
formattedData.push({ value: 0, label: '24시', frontColor: 'transparent' });
continue;
}
const item = hourlyData.find(d => d.hour === i) || {};
// 현재 시간인지 확인 (오늘 날짜이고 현재 시간인 경우)
const isToday = new Date().toDateString() === currentDate.toDateString();
const isCurrentHour = isToday && (i === new Date().getHours());
const hasData = item.has_data || item.current_kw > 0;
let color = '#E5E7EB';
if (isCurrentHour) {
color = '#10B981';
} else if (hasData) {
color = '#3B82F6';
}
formattedData.push({
value: item.current_kw || 0,
label: i % 2 === 0 ? `${i}` : '',
frontColor: color,
onPress: () => i < 24 && showTooltip(item.current_kw || 0, `${i}`),
});
}
setChartData(formattedData);
// Today 탭이지만 과거 날짜인 경우 상단 요약 카드 데이터 갱신 필요
// API 결과에 daily total이 포함되어 있다고 가정하거나, hourly sum을 해야 함.
// 현재 API(stats/today)는 today_kwh를 주지만, 이는 그 시간대까지의 누적임.
// 마지막 시간대의 today_kwh가 그날의 총 발전량.
// 편의상 차트 데이터만 갱신.
} else {
// 기존 일간/월간/연간 조회
// 쿼리 파라미터 추가
let query = `period=${period}`;
if (period === 'day') {
// period=day -> year, month 필요
query += `&year=${y}&month=${m}`;
} else if (period === 'month') {
// period=month -> year 필요
query += `&year=${y}`;
} else if (period === 'year') {
// period=year -> year 필요 (기준 연도)
query += `&year=${y}`;
}
const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats?${query}`);
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const result = await response.json();
const rawData = result.data || [];
const formattedData = [];
if (period === 'day') {
// 선택된 달의 마지막 날
const lastDay = new Date(y, m, 0).getDate();
const dataMap = {};
rawData.forEach(item => { dataMap[item.label] = item.value; });
for (let day = 1; day <= lastDay; day++) {
const ds = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const val = dataMap[ds] || 0;
const showLabel = (day === 1 || day % 5 === 0);
formattedData.push({
value: val,
label: showLabel ? `${day}` : '',
labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 },
frontColor: val > 0 ? '#3B82F6' : '#E5E7EB',
});
}
} else if (period === 'month') {
// 1월 ~ 12월
const dataMap = {};
rawData.forEach(item => { dataMap[item.label] = item.value; });
for (let month = 1; month <= 12; month++) {
const ms = `${y}-${String(month).padStart(2, '0')}`;
const val = dataMap[ms] || 0;
formattedData.push({
value: val,
label: `${month}`,
labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -5 },
frontColor: val > 0 ? '#3B82F6' : '#E5E7EB',
renderTooltip: renderTooltip,
});
}
} else {
// year (기존 유지)
rawData.forEach(item => {
formattedData.push({
value: item.value || 0,
label: formatLabel(item.label, period),
labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 },
frontColor: item.value > 0 ? '#3B82F6' : '#E5E7EB',
renderTooltip: renderTooltip,
});
});
}
setChartData(formattedData);
}
} catch (err) {
setError(err.message);
setChartData([]);
} finally {
setLoading(false);
}
}, [plant.id, period, currentDate]);
// 날짜 포맷팅 (YYYY년 MM월 DD일 등)
const formatDateDisplay = () => {
const y = currentDate.getFullYear();
const m = currentDate.getMonth() + 1;
const d = currentDate.getDate();
if (period === 'today') return `${y}${m}${d}`;
if (period === 'day') return `${y}${m}`;
if (period === 'month') return `${y}`;
if (period === 'year') return `${y - 4}년 ~ ${y}`;
return `${y}년 기준`;
};
// 기간 설명 (수정)
const getPeriodDescription = () => {
return formatDateDisplay();
};
// 탭 렌더링 (아래 날짜 컨트롤 추가)
// ... renderTabs는 그대로 두고, 호출하는 곳에서 컨트롤 추가
// 날짜 컨트롤 렌더링
const renderDateControl = () => (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginVertical: 12, gap: 16 }}>
<TouchableOpacity onPress={() => moveDate(-1)} style={{ padding: 8 }}>
<Text style={{ fontSize: 20, color: '#4B5563' }}></Text>
</TouchableOpacity>
<Text style={{ fontSize: 16, fontWeight: 'bold', color: '#1F2937' }}>
{formatDateDisplay()}
</Text>
<TouchableOpacity onPress={() => moveDate(1)} style={{ padding: 8 }}>
<Text style={{ fontSize: 20, color: '#4B5563' }}></Text>
</TouchableOpacity>
</View>
);
useEffect(() => {
fetchStats();
}, [fetchStats]);
// 툴팁 표시
// 툴팁 렌더링 (커스텀)
const renderTooltip = (item, index) => {
return (
<View style={{
marginBottom: 20,
marginLeft: -6,
backgroundColor: 'rgba(0,0,0,0.8)',
paddingHorizontal: 6,
paddingVertical: 4,
borderRadius: 4,
}}>
<Text style={{ color: '#fff', fontSize: 10 }}>
{item.value.toFixed(1)}{period === 'today' ? 'kW' : 'kWh'}
</Text>
</View>
);
};
// 라벨 포맷팅
const formatLabel = (label, period) => {
if (!label) return '';
if (period === 'day') {
const parts = label.split('-');
return parts.length >= 3 ? `${parseInt(parts[2], 10)}` : label;
} else if (period === 'month') {
const parts = label.split('-');
// 앞에 0 제거하고 '월' 추가 (예: 01 -> 1월)
return parts.length >= 2 ? `${parseInt(parts[1], 10)}` : label;
} else {
return label.length === 4 ? `${label.slice(2)}` : label;
}
};
// 총 발전량 계산
const getTotalGeneration = () => {
return chartData.reduce((sum, item) => sum + (item.value || 0), 0);
};
// 엑셀 업로드 핸들러
const handleUpload = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
],
copyToCacheDirectory: true,
});
if (result.canceled) return;
const file = result.assets[0];
setUploading(true);
const formData = new FormData();
if (file.file) {
// Web: 실제 File 객체 사용
formData.append('file', file.file);
} else {
// Native: URI 객체 사용
formData.append('file', {
uri: file.uri,
type: file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
name: file.name,
});
}
const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/upload/monthly`, {
method: 'POST',
body: formData,
});
const responseData = await response.json();
if (!response.ok) throw new Error(responseData.detail || '업로드 실패');
Alert.alert(
'업로드 완료',
responseData.message || `${responseData.saved_count}건의 데이터가 저장되었습니다.`,
[{ text: '확인' }]
);
fetchStats();
} catch (err) {
Alert.alert('업로드 실패', err.message, [{ text: '확인' }]);
} finally {
setUploading(false);
}
};
// 탭 렌더링
const renderTabs = () => (
<View style={styles.tabContainer}>
{[
{ key: 'today', label: '오늘' },
{ key: 'day', label: '일간' },
{ key: 'month', label: '월간' },
{ key: 'year', label: '연간' },
].map((tab) => (
<TouchableOpacity
key={tab.key}
style={[styles.tab, period === tab.key && styles.tabActive]}
onPress={() => setPeriod(tab.key)}
>
<Text style={[styles.tabText, period === tab.key && styles.tabTextActive]}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
);
// 기간 설명 (수정 - 위에서 이미 정의됨, 중복 제거)
// const getPeriodDescription = ... (removed)
const chartWidth = Math.min(width - 60, 600);
// 막대 너비 및 간격 계산
let barWidth = 10;
let spacing = 10;
if (period === 'today') {
// 화면 꽉 차게 (25개 항목: 0~24시) - 스크롤 없애기 위함
const availableWidth = chartWidth - 20;
const itemWidth = availableWidth / 25;
barWidth = Math.max(4, itemWidth * 0.6); // 60% 바
spacing = Math.max(2, itemWidth * 0.4); // 40% 여백
} else if (period === 'year') {
barWidth = 40;
spacing = 20;
} else if (period === 'month') {
barWidth = 24;
spacing = 12;
} else { // day
barWidth = 10;
spacing = 3;
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* 헤더 */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
<Text style={styles.backButtonText}> 뒤로</Text>
</TouchableOpacity>
<Text style={styles.headerTitle}>{plant.name}</Text>
<View style={styles.headerSpacer} />
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
{/* 날짜 컨트롤 */}
{renderDateControl()}
{/* 탭 */}
{renderTabs()}
{/* 오늘 실시간 현황 카드 (today 탭일 때만) */}
{period === 'today' && todayData && (
<View style={styles.todayCard}>
<View style={styles.todayHeader}>
<Text style={styles.todayTitle}> 실시간 발전 현황</Text>
<Text style={styles.todayTime}>갱신: {todayData.updatedAt}</Text>
</View>
<View style={styles.todayStats}>
<View style={styles.todayStat}>
<Text style={styles.todayStatValue}>{todayData.currentKw.toFixed(1)}</Text>
<Text style={styles.todayStatLabel}>현재 출력 (kW)</Text>
</View>
<View style={styles.todayDivider} />
<View style={styles.todayStat}>
<Text style={styles.todayStatValue}>{todayData.todayKwh.toFixed(1)}</Text>
<Text style={styles.todayStatLabel}>금일 발전량 (kWh)</Text>
</View>
</View>
</View>
)}
{/* 차트 영역 */}
<View style={styles.chartSection}>
<View style={styles.chartHeader}>
<Text style={styles.sectionTitle}>
📊 {period === 'today' ? '시간대별 출력' : '발전량 추이'} ({getPeriodDescription()})
</Text>
<Text style={styles.chartHint}>막대를 터치하면 상세 정보</Text>
</View>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3B82F6" />
<Text style={styles.loadingText}>데이터 로딩 ...</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}> 데이터를 불러올 없습니다</Text>
<Text style={styles.errorDetail}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={fetchStats}>
<Text style={styles.retryButtonText}>다시 시도</Text>
</TouchableOpacity>
</View>
) : chartData.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>📭 표시할 데이터가 없습니다</Text>
<Text style={styles.emptySubtext}>
{period === 'today' ? '크롤러가 데이터를 수집하면 표시됩니다' : '엑셀 파일을 업로드해 주세요'}
</Text>
</View>
) : (
<View style={styles.chartContainer}>
<BarChart
data={chartData}
maxValue={Math.max(...chartData.map(d => d.value || 0)) * 1.2 || 10} // 툴팁 공간 확보 (20%)
width={chartWidth}
height={220}
barWidth={barWidth}
spacing={spacing}
barBorderRadius={4}
frontColor="#3B82F6"
yAxisThickness={1}
xAxisThickness={1}
yAxisColor="#E5E7EB"
xAxisColor="#E5E7EB"
yAxisTextStyle={{ color: '#6B7280', fontSize: 10 }}
xAxisLabelTextStyle={{ color: '#6B7280', fontSize: 9 }}
noOfSections={5}
hideRules={false}
rulesColor="#F3F4F6"
showValuesAsTopLabel={false}
isAnimated
yAxisSuffix={period === 'today' ? '' : ''}
renderTooltip={renderTooltip}
/>
</View>
)}
</View>
{/* 통계 요약 (today 제외) */}
{period !== 'today' && (
<View style={styles.summarySection}>
<Text style={styles.sectionTitle}>📈 통계 요약</Text>
<View style={styles.summaryCards}>
<View style={styles.summaryCard}>
<Text style={styles.summaryLabel}> 발전량</Text>
<Text style={styles.summaryValue}>
{getTotalGeneration().toLocaleString(undefined, { maximumFractionDigits: 1 })}
</Text>
<Text style={styles.summaryUnit}>kWh</Text>
</View>
<View style={styles.summaryCard}>
<Text style={styles.summaryLabel}>데이터 </Text>
<Text style={styles.summaryValue}>{chartData.length}</Text>
<Text style={styles.summaryUnit}></Text>
</View>
<View style={styles.summaryCard}>
<Text style={styles.summaryLabel}>설비 용량</Text>
<Text style={styles.summaryValue}>{plant.capacity || '-'}</Text>
<Text style={styles.summaryUnit}>kW</Text>
</View>
</View>
</View>
)}
{/* 업로드 버튼 */}
<View style={styles.uploadSection}>
<TouchableOpacity
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
onPress={handleUpload}
disabled={uploading}
>
{uploading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.uploadButtonText}>📂 과거 엑셀 데이터 업로드</Text>
)}
</TouchableOpacity>
<Text style={styles.uploadHint}>
* 엑셀 파일에 'date', 'generation' 컬럼이 필요합니다
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F3F4F6',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#1E40AF',
paddingHorizontal: 16,
paddingVertical: 16,
},
backButton: {
paddingVertical: 8,
paddingHorizontal: 4,
},
backButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '500',
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#FFFFFF',
flex: 1,
textAlign: 'center',
},
headerSpacer: {
width: 60,
},
content: {
flex: 1,
},
contentContainer: {
padding: 16,
gap: 16,
},
// Tabs
tabContainer: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
tab: {
flex: 1,
paddingVertical: 12,
alignItems: 'center',
borderRadius: 8,
},
tabActive: {
backgroundColor: '#3B82F6',
},
tabText: {
fontSize: 13,
fontWeight: '600',
color: '#6B7280',
},
tabTextActive: {
color: '#FFFFFF',
},
// Today Card
todayCard: {
backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
backgroundColor: '#1E40AF',
borderRadius: 16,
padding: 20,
shadowColor: '#1E40AF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
todayHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
todayTitle: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
},
todayTime: {
fontSize: 12,
color: '#93C5FD',
},
todayStats: {
flexDirection: 'row',
alignItems: 'center',
},
todayStat: {
flex: 1,
alignItems: 'center',
},
todayStatValue: {
fontSize: 36,
fontWeight: 'bold',
color: '#FFFFFF',
},
todayStatLabel: {
fontSize: 12,
color: '#93C5FD',
marginTop: 4,
},
todayDivider: {
width: 1,
height: 50,
backgroundColor: 'rgba(255,255,255,0.3)',
marginHorizontal: 16,
},
// Chart Section
chartSection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
chartHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
color: '#1F2937',
},
chartHint: {
fontSize: 11,
color: '#9CA3AF',
},
chartContainer: {
alignItems: 'center',
paddingVertical: 10,
},
loadingContainer: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
color: '#6B7280',
fontSize: 14,
},
errorContainer: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: '#EF4444',
marginBottom: 8,
},
errorDetail: {
fontSize: 12,
color: '#6B7280',
marginBottom: 16,
},
retryButton: {
backgroundColor: '#3B82F6',
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: '#FFFFFF',
fontWeight: '600',
},
emptyContainer: {
height: 200,
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
fontSize: 18,
color: '#6B7280',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#9CA3AF',
},
// Summary Section
summarySection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
summaryCards: {
flexDirection: 'row',
gap: 12,
marginTop: 12,
},
summaryCard: {
flex: 1,
backgroundColor: '#F0F9FF',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
summaryLabel: {
fontSize: 12,
color: '#6B7280',
marginBottom: 4,
},
summaryValue: {
fontSize: 24,
fontWeight: 'bold',
color: '#1E40AF',
},
summaryUnit: {
fontSize: 12,
color: '#6B7280',
marginTop: 2,
},
// Upload Section
uploadSection: {
alignItems: 'center',
paddingVertical: 8,
},
uploadButton: {
backgroundColor: '#10B981',
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 12,
width: '100%',
alignItems: 'center',
shadowColor: '#10B981',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
uploadButtonDisabled: {
backgroundColor: '#9CA3AF',
},
uploadButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
},
uploadHint: {
marginTop: 12,
fontSize: 12,
color: '#9CA3AF',
},
});

View File

@ -1,33 +0,0 @@
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"

49
crawler/.gitignore vendored
View File

@ -1,49 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
.venv
venv/
ENV/
env.bak/
venv.bak/
# Environment Variables
.env
.env.local
# IDE
.vscode/
.idea/
# Custom
*.log
*.sqlite3
crawler_manager.db
temp_env/
tests/db_dump.csv
tests/results.csv
tests/*_log.txt

View File

@ -1,50 +0,0 @@
# 윈도우 개발 환경 가이드
## 1. 개요
이 프로젝트는 Windows와 NAS(리눅스) 환경 모두에서 동작하도록 구성되어 있습니다.
NAS 배포 전 Windows 환경에서 테스트 및 데이터 복구를 수행할 수 있습니다.
## 2. 가상환경 (Windows)
윈도우용 가상환경은 `venv_win` 폴더에 구성되어 있습니다.
```powershell
# 가상환경 활성화
.\venv_win\Scripts\activate
# 의존성 설치
pip install -r requirements.txt
```
## 3. 개발 도구 (tools 폴더)
`tools/` 폴더에는 데이터 검증 및 복구를 위한 유틸리티 스크립트가 포함되어 있습니다.
### 3.1 DB 데이터 조회 (check_db.py)
특정 시간대의 Supabase 데이터가 정상적으로 저장되었는지 확인합니다.
- UTC 기준으로 조회하므로 KST 변환에 유의하세요.
- 사용법:
```powershell
python tools/check_db.py
```
### 3.2 로그 기반 데이터 복구 (recover_from_log.py)
`cron.log` 등의 로그 파일을 파싱하여 누락된 데이터를 DB에 다시 채워넣습니다.
- `clean_recover.py` 기능을 개선하여 포함했습니다.
- 사용법:
```powershell
python tools/recover_from_log.py
```
## 4. 실행 및 테스트
메인 크롤러 실행:
```powershell
python main.py
```
GUI 대시보드 실행 (테스트용):
```powershell
python crawler_gui.py
```
## 5. 배포 시 주의사항
- `crawler_manager.py``site_data.db`는 로컬에 생성되므로 배포 시 제외하거나 초기화 상태로 배포하세요.
- `.env` 파일의 API 키가 만료되지 않았는지 확인하세요.

View File

@ -1,157 +0,0 @@
import sqlite3
import requests
from datetime import datetime
from pathlib import Path
from config import TELEGRAM_BOT_TOKEN
class AlertManager:
"""
발전소 이상 감지 텔레그램 알림 관리
- 상태(정상/이상) DB에 저장하여 중복 알림 방지
"""
def __init__(self, db_path: str = None):
"""
DB 연결 테이블 초기화
"""
if db_path is None:
# crawler_manager와 같은 DB 파일 사용
db_path = Path(__file__).parent / "crawler_manager.db"
self.db_path = str(db_path)
self._init_db()
def _init_db(self):
"""알림 히스토리 테이블 생성"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# site_id: 발전소 ID
# alert_status: 'NORMAL' (정상), 'ALERT' (이상 발생 및 알림 전송됨)
# last_alert_time: 마지막 알림 전송 시간
cursor.execute("""
CREATE TABLE IF NOT EXISTS alert_history (
site_id TEXT PRIMARY KEY,
alert_status TEXT DEFAULT 'NORMAL',
last_alert_time TEXT
)
""")
conn.commit()
def send_telegram_message(self, chat_id, message):
"""텔레그램 메시지 전송"""
if not TELEGRAM_BOT_TOKEN:
print(" ⚠️ 텔레그램 토큰이 설정되지 않았습니다.")
return False
if not chat_id:
# Chat ID가 설정되지 않은 경우 조용히 리턴 (로그는 호출부에서 처리)
return False
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
try:
payload = {"chat_id": chat_id, "text": message}
response = requests.post(url, json=payload, timeout=15)
if response.status_code == 200:
print(f" 🔔 텔레그램 알림 전송 성공")
return True
else:
print(f" ❌ 텔레그램 전송 실패 ({response.status_code}): {response.text}")
return False
except Exception as e:
print(f" ❌ 텔레그램 전송 중 에러: {e}")
return False
def check_and_alert(self, plant_info: dict, current_kw: float):
"""
발전량을 체크하고 필요 알림 전송
- 오전 10 ~ 오후 5시에만 동작
- 상태 변경 시에만 알림 (중복 방지)
"""
# 1. 시간 체크 (오전 10시 ~ 오후 5시)
now = datetime.now()
if not (10 <= now.hour <= 17):
return
site_id = plant_info.get('id')
plant_name = plant_info.get('display_name', plant_info.get('name'))
chat_id = plant_info.get('telegram_chat_id')
if not site_id:
return
# 1.5. DB에서 알림 활성화 상태 확인
try:
from database import get_supabase_client
client = get_supabase_client()
if client:
company_id = plant_info.get('company_id', 1)
resp = client.table("plants").select("alerts_enabled").eq("id", site_id).eq("company_id", company_id).execute()
if resp.data and resp.data[0].get('alerts_enabled') is False:
print(f" 🔇 [Alert] {plant_name}: 알림이 비활성화되어 있습니다.")
return
except Exception as e:
print(f" ⚠️ 알림 설정 확인 중 오류: {e}")
# 2. 현재 DB 상태 확인
current_status = 'NORMAL'
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT alert_status FROM alert_history WHERE site_id = ?", (site_id,))
row = cursor.fetchone()
if row:
current_status = row[0]
else:
# 초기값 생성
cursor.execute("INSERT INTO alert_history (site_id, alert_status) VALUES (?, ?)", (site_id, 'NORMAL'))
conn.commit()
# 3. 상태 전이 로직
new_status = current_status
# [Case A] 발전량 0 (이상 감지)
if current_kw == 0:
if current_status == 'NORMAL':
# NORMAL -> ALERT: 알림 전송
print(f" 🚨 [Alert] {plant_name} 발전량 0kW 감지! 알림 전송 시도...")
if chat_id:
message = (
f"🚨 [긴급] 발전소 이상 감지!\n\n"
f"- 발전소: {plant_name}\n"
f"- 상태: 발전량 0kW\n"
f"- 시간: {now.strftime('%Y-%m-%d %H:%M:%S')}"
)
if self.send_telegram_message(chat_id, message):
new_status = 'ALERT'
else:
print(f" ⚠️ {plant_name}: Chat ID 오류로 알림 실패")
# 전송 실패해도 상태를 ALERT로 할 것인가?
# 실패했다면 다음에 다시 시도해야 하므로 NORMAL 유지
else:
print(f" ⚠️ {plant_name}: 설정된 Chat ID가 없습니다. (config.py 확인)")
else:
# 이미 ALERT 상태: 중복 알림 생략
pass
# [Case B] 발전량 > 0 (정상 복구)
else:
if current_status == 'ALERT':
# ALERT -> NORMAL: 상태 리셋
print(f" ✅ [Alert] {plant_name} 정상 복구됨 ({current_kw}kW)")
# 복구 알림은 옵션 (현재는 생략)
new_status = 'NORMAL'
# 4. 상태 변경 시 DB 업데이트
if new_status != current_status:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE alert_history
SET alert_status = ?, last_alert_time = ?
WHERE site_id = ?
""", (new_status, now.isoformat(), site_id))
conn.commit()

View File

@ -1,229 +0,0 @@
# ==========================================
# config.py - 다중 업체(Multi-Tenant) 설정 관리
# ==========================================
# ---------------------------------------------------------
# [프록시 설정 - 클라우드 이전용]
# 오라클 서버 등 외부 망에서 접속할 때 NAS의 인터넷을 빌려 쓰기 위한 설정입니다.
# ---------------------------------------------------------
USE_PROXY = False # True로 변경하면 모든 크롤링이 아래 프록시를 경유합니다.
PROXY_URL = "http://100.83.7.81:3128"
PROXIES = {
"http": PROXY_URL,
"https": PROXY_URL,
}
# ---------------------------------------------------------
# [시스템 상수] 각 크롤러 시스템의 URL 및 엔드포인트
# ---------------------------------------------------------
SYSTEM_CONSTANTS = {
'nrems': {
'api_url': 'http://www.nrems.co.kr/v2/local/proc/index_proc.php',
'detail_url': 'http://www.nrems.co.kr/v2/local/comp/cp_inv.php',
'inv_proc_url': 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
},
'kremc': {
'login_url': 'https://kremc.kr/api/v2.2/login',
'api_base': 'https://kremc.kr/api/v2.2',
'enso_type': '15001'
},
'sun_wms': {
'base_url': 'http://tb6.sun-wms.com',
'login_url': 'http://tb6.sun-wms.com/public/main/login_chk.php',
'data_url': 'http://tb6.sun-wms.com/public/main/realdata.php',
'statics_url': 'http://tb6.sun-wms.com/public/statics/statics.php'
},
'hyundai': {
'base_url': 'https://hs3.hyundai-es.co.kr',
'login_path': '/hismart/login',
'data_path': '/hismart/site/getSolraUnitedWork'
},
'cmsolar': {
'base_url': 'http://www.cmsolar2.kr',
'api_url': 'http://www.cmsolar2.kr',
'login_url': 'http://www.cmsolar2.kr/login_ok.php',
'data_url': 'http://www.cmsolar2.kr/plant/sub/report_ok.php'
}
}
# ---------------------------------------------------------
# [텔레그램 봇 설정]
# ---------------------------------------------------------
# https://t.me/BotFather 로 생성한 봇 토큰
# 사용자는 봇에게 먼저 메시지를 보내야 Chat ID를 알 수 있습니다.
TELEGRAM_BOT_TOKEN = '8273363609:AAEGv4abJSORNkap6XO_mqbnBKemBOEjugI'
# ---------------------------------------------------------
# [업체 목록] 업체 > 발전소 계층 구조
# ---------------------------------------------------------
COMPANIES = [
{
'company_id': 'sunwind',
'company_name': '태양과바람',
'telegram_chat_id': -1003713715004, # 그룹이 슈퍼그룹으로 업그레이드되어 변경된 새 ID 적용
'plants': [
# NREMS 계열 - 1, 2호기 (분리 처리)
# id는 크롤러 내부에서 'nrems-01', 'nrems-02'로 분리 할당
{
'name': '1호기, 2호기',
'display_name': 'SPLIT_1_2',
'type': 'nrems',
'auth': {
'pscode': 'duce2023072288'
},
'options': {
'is_split': True
},
'start_date': '2014-03-31',
'capacity_kw': 100.0 # 1호기 50kW + 2호기 50kW
# id는 크롤러에서 동적 할당 (nrems-01, nrems-02)
},
# NREMS 계열 - 3호기
{
'id': 'nrems-03',
'name': '3호기',
'type': 'nrems',
'auth': {
'pscode': 'dc2023121086'
},
'options': {
'is_split': False
},
'start_date': '2015-12-22',
'capacity_kw': 99.82
},
# NREMS 계열 - 4호기
{
'id': 'nrems-04',
'name': '4호기',
'type': 'nrems',
'auth': {
'pscode': 'dc2023121085'
},
'options': {
'is_split': False
},
'start_date': '2017-01-11',
'capacity_kw': 88.2
},
# NREMS 계열 - 9호기
{
'id': 'nrems-09',
'name': '9호기',
'type': 'nrems',
'auth': {
'pscode': 'a2020061008'
},
'options': {
'is_split': False
},
'start_date': '2020-10-28',
'capacity_kw': 99.12
},
# KREMC - 5호기
{
'id': 'kremc-05',
'name': '5호기',
'type': 'kremc',
'auth': {
'user_id': '서대문도서관',
'password': 'sunhope5!'
},
'options': {
'cid': '10013000376',
'cityProvCode': '11',
'rgnCode': '11410',
'dongCode': '1141011700'
},
'start_date': '2018-06-28',
'capacity_kw': 42.7
},
# Sun-WMS - 6호기
{
'id': 'sunwms-06',
'name': '6호기',
'type': 'sun_wms',
'auth': {
'payload_id': 'kc0fXUW0LUm2wZa+2NQI0Q==',
'payload_pw': 'PGXjU6ib2mKYwtrh2i3fIQ=='
},
'options': {},
'start_date': '2019-12-30',
'capacity_kw': 49.9
},
# 현대 - 8호기
{
'id': 'hyundai-08',
'name': '8호기',
'type': 'hyundai',
'auth': {
'user_id': 'epecoop',
'password': 'sunhope0419',
'site_id': 'M0494'
},
'options': {},
'start_date': '2020-02-06',
'capacity_kw': 99.9
},
# CMSolar - 10호기
{
'id': 'cmsolar-10',
'name': '10호기',
'type': 'cmsolar',
'auth': {
'login_id': 'sy7144',
'login_pw': 'sy7144',
'site_no': '834'
},
'options': {},
'start_date': '2020-08-31',
'capacity_kw': 31.5
}
]
}
]
# ---------------------------------------------------------
# [헬퍼 함수] 평탄화된 발전소 리스트 반환
# ---------------------------------------------------------
def get_all_plants():
"""
모든 업체의 발전소 정보를 평탄화하여 반환
"""
all_plants = []
for company in COMPANIES:
company_id = company.get('company_id', '')
company_name = company.get('company_name', '')
telegram_chat_id = company.get('telegram_chat_id')
for plant in company.get('plants', []):
plant_type = plant.get('type', '')
system_config = SYSTEM_CONSTANTS.get(plant_type, {})
plant_info = {
'company_id': company_id,
'company_name': company_name,
'telegram_chat_id': telegram_chat_id,
'id': plant.get('id', ''), # DB용 고유 ID
'name': plant.get('name', ''),
'display_name': plant.get('display_name', plant.get('name', '')),
'type': plant_type,
'auth': plant.get('auth', {}),
'options': plant.get('options', {}),
'start_date': plant.get('start_date', ''),
'capacity_kw': plant.get('capacity_kw', 0.0),
'system': system_config
}
all_plants.append(plant_info)
return all_plants
def get_plants_by_company(company_id):
"""특정 업체의 발전소만 반환"""
return [p for p in get_all_plants() if p['company_id'] == company_id]
def get_plants_by_type(plant_type):
"""특정 타입의 발전소만 반환"""
return [p for p in get_all_plants() if p['type'] == plant_type]

View File

@ -1,404 +0,0 @@
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
import threading
import subprocess
import sys
import os
import json
import sqlite3
from datetime import datetime
import time
# 프로젝트 루트 경로 추가
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
sys.path.append(project_root)
# 모듈 import 시도 (실패 시 예외처리)
try:
from config import get_all_plants
from crawler_manager import CrawlerManager
except ImportError:
# GUI 단독 실행 시 더미 데이터 사용 가능하도록
pass
class CrawlerControlPanel:
def __init__(self, root):
self.root = root
self.root.title("☀️ 태양광 발전 통합 관제 시스템 [관리자 모드]")
self.root.geometry("1100x750")
self.root.configure(bg="#f0f2f5")
# 스타일 설정
self.setup_styles()
# 데이터 매니저 초기화
try:
self.manager = CrawlerManager(os.path.join(project_root, "crawler_manager.db"))
self.plants = get_all_plants()
except:
self.manager = None
self.plants = []
# 메인 레이아웃
self.create_layout()
# 초기 데이터 로드
self.refresh_monitor()
def setup_styles(self):
style = ttk.Style()
style.theme_use('clam')
# 프리미엄 색상 팔레트
colors = {
'primary': '#2563eb',
'secondary': '#64748b',
'success': '#16a34a',
'danger': '#dc2626',
'bg': '#f8fafc',
'card': '#ffffff'
}
style.configure("Header.TLabel", font=("Malgun Gothic", 16, "bold"), background="#f0f2f5", foreground="#1e293b")
style.configure("Section.TLabel", font=("Malgun Gothic", 12, "bold"), background="#f0f2f5", foreground="#334155")
style.configure("Card.TFrame", background="#ffffff", relief="flat")
# 트리뷰 스타일 (표)
style.configure("Treeview",
background="#ffffff",
fieldbackground="#ffffff",
font=("Malgun Gothic", 10),
rowheight=30
)
style.configure("Treeview.Heading",
font=("Malgun Gothic", 10, "bold"),
background="#e2e8f0",
foreground="#1e293b"
)
# 버튼 스타일
style.configure("Action.TButton", font=("Malgun Gothic", 10), padding=6)
style.map("Action.TButton", background=[("active", "#dbeafe")])
def create_layout(self):
# 상단 헤더
header_frame = ttk.Frame(self.root, padding="20 20 20 10")
header_frame.pack(fill="x")
ttk.Label(header_frame, text="⚡ SolorPower Crawler Control", style="Header.TLabel").pack(side="left")
status_frame = ttk.Frame(header_frame)
status_frame.pack(side="right")
self.status_label = ttk.Label(status_frame, text="🟢 시스템 대기중", font=("Malgun Gothic", 10), foreground="green")
self.status_label.pack()
# 메인 컨텐츠 (좌우 분할)
main_paned = ttk.PanedWindow(self.root, orient="horizontal")
main_paned.pack(fill="both", expand=True, padx=20, pady=10)
# 좌측 패널: 발전소 목록 및 제어
left_frame = ttk.Frame(main_paned)
main_paned.add(left_frame, weight=2)
# 우측 패널: 로그 및 상세 정보
right_frame = ttk.Frame(main_paned)
main_paned.add(right_frame, weight=1)
# --- 좌측 패널 구성 ---
# 1. 제어 버튼 그룹
control_frame = ttk.LabelFrame(left_frame, text="통합 제어", padding=15)
control_frame.pack(fill="x", pady=(0, 15))
btn_grid = ttk.Frame(control_frame)
btn_grid.pack(fill="x")
ttk.Button(btn_grid, text="▶ 전체 수집 시작", command=self.run_all_crawlers, style="Action.TButton").pack(side="left", padx=5)
ttk.Button(btn_grid, text="🔄 새로고침", command=self.refresh_monitor, style="Action.TButton").pack(side="left", padx=5)
ttk.Button(btn_grid, text="📊 통계 요약 실행", command=self.run_daily_summary, style="Action.TButton").pack(side="left", padx=5)
# 2. 발전소 모니터링 테이블
table_frame = ttk.LabelFrame(left_frame, text="발전소 모니터링 현황", padding=10)
table_frame.pack(fill="both", expand=True)
columns = ("site_id", "name", "type", "status", "schedule", "last_run", "action", "history")
self.tree = ttk.Treeview(table_frame, columns=columns, show="tree headings", selectmode="browse")
self.tree.heading("site_id", text="ID")
self.tree.heading("name", text="발전소명")
self.tree.heading("type", text="타입")
self.tree.heading("status", text="상태")
self.tree.heading("schedule", text="스케줄")
self.tree.heading("last_run", text="최근 실행")
self.tree.heading("action", text="개별 제어")
self.tree.heading("history", text="과거 데이터")
self.tree.column("site_id", width=80)
self.tree.column("name", width=150)
self.tree.column("type", width=80)
self.tree.column("status", width=80)
self.tree.column("schedule", width=100)
self.tree.column("last_run", width=140)
self.tree.column("action", width=80)
self.tree.column("history", width=80)
scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
self.tree.configure(yscroll=scrollbar.set)
self.tree.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# 우클릭 메뉴 (복구)
self.context_menu = tk.Menu(self.root, tearoff=0)
self.context_menu.add_command(label="▶ 이 사이트만 즉시 실행", command=self.run_selected_crawler)
self.context_menu.add_command(label="📑 상세 로그 보기", command=self.show_site_logs)
self.context_menu.add_separator()
self.context_menu.add_command(label="🔄 학습 모드로 리셋", command=self.reset_learning_mode)
# 이벤트 바인딩
self.tree.bind("<ButtonRelease-1>", self.on_tree_click)
self.tree.bind("<Button-3>", self.show_context_menu)
self.tree.bind("<Double-1>", lambda e: self.run_selected_crawler())
# --- 우측 패널 구성 ---
# 실시간 로그 뷰어
log_frame = ttk.LabelFrame(right_frame, text="실시간 시스템 로그", padding=10)
log_frame.pack(fill="both", expand=True)
self.log_text = scrolledtext.ScrolledText(log_frame, state='disabled', font=("Consolas", 9), bg="#1e293b", fg="#e2e8f0")
self.log_text.pack(fill="both", expand=True)
# 태그 설정 (로그 색상)
self.log_text.tag_config("INFO", foreground="#60a5fa")
self.log_text.tag_config("SUCCESS", foreground="#4ade80")
self.log_text.tag_config("ERROR", foreground="#f87171")
self.log_text.tag_config("WARNING", foreground="#fbbf24")
def log(self, message, level="INFO"):
"""로그 창에 메시지 출력"""
timestamp = datetime.now().strftime("%H:%M:%S")
full_msg = f"[{timestamp}] {message}\n"
self.log_text.configure(state='normal')
self.log_text.insert("end", full_msg, level)
self.log_text.see("end")
self.log_text.configure(state='disabled')
def refresh_monitor(self):
"""테이블 데이터 새로고침"""
# 기존 항목 제거
for i in self.tree.get_children():
self.tree.delete(i)
if not self.manager:
self.log("DB 매니저 로드 실패", "ERROR")
return
# DB에서 최신 상태 조회
site_stats = {s['site_id']: s for s in self.manager.get_all_sites()}
# 중복 회사 노드 방지용
added_companies = set()
for plant in self.plants:
# 1,2호기 분리 로직 반영
is_split = plant.get('options', {}).get('is_split', False)
company_name = plant.get('company_name', '')
plant_name = plant.get('name', '')
sub_units = []
if is_split:
sub_units.append({'id': 'nrems-01', 'name': f'{company_name} 1호기', 'type': plant['type']})
sub_units.append({'id': 'nrems-02', 'name': f'{company_name} 2호기', 'type': plant['type']})
else:
plant_id = plant.get('id', '')
if plant_id:
sub_units.append({'id': plant_id, 'name': f'{company_name} {plant_name}', 'type': plant['type']})
for unit in sub_units:
site_id = unit['id']
stat = site_stats.get(site_id, {})
status_text = stat.get('status', 'UNREGISTERED')
schedule_text = f"매시 {stat.get('target_minute', -1)}" if stat.get('target_minute', -1) >= 0 else "학습중"
last_run = stat.get('last_run', '-') or '-'
if last_run != '-':
try:
last_run = last_run.split('.')[0].replace('T', ' ') # 포맷팅
except: pass
# 태그 설정 (색상)
row_tag = "normal"
if status_text == 'OPTIMIZED': row_tag = "optimized"
# 회사 노드 확인 및 생성
company_id = plant.get('company_id', 'unknown')
if company_id not in added_companies:
self.tree.insert("", "end", iid=company_id, text=company_name, values=(
"", company_name, "GROUP", "", "", "", "", ""
), open=True)
added_companies.add(company_id)
# 발전소 노드 추가 (회사 노드 하위)
self.tree.insert(company_id, "end", iid=site_id, values=(
site_id,
unit['name'],
unit['type'].upper(),
status_text,
schedule_text,
last_run,
"▶ 실행",
"📥 수집"
), tags=(row_tag,))
self.tree.tag_configure("optimized", foreground="#059669") # 진한 녹색
self.log("모니터링 상태 갱신 완료 (계층형)", "INFO")
def on_tree_click(self, event):
"""트리뷰 클릭 이벤트 처리"""
try:
region = self.tree.identify_region(event.x, event.y)
if region != "cell": return
col = self.tree.identify_column(event.x)
item_id = self.tree.identify_row(event.y)
if not item_id: return
# 컬럼 인덱스 확인 (columns 배열 기준 1-based, #1=site_id, ... #7=action, #8=history)
# Treeview columns: ("site_id", "name", "type", "status", "schedule", "last_run", "action", "history")
# Display columns include transparent tree column if show="tree headings"
# identify_column returns '#N'.
# #1: site_id, #7: action, #8: history
if col == '#7': # Action (실행)
self.log(f"'{item_id}' 실행 요청", "INFO")
# TODO: 개별 실행
self.run_process_thread(["main.py", "--site", item_id], f"{item_id} 수집")
elif col == '#8': # History (과거 데이터)
# 그룹 노드는 제외
if self.tree.parent(item_id) == "":
return
if messagebox.askyesno("과거 데이터 수집", f"'{item_id}'의 과거 내역을 수집하시겠습니까?\n(시간별/일별/월별 전체)"):
self.run_process_thread(["fetch_history.py", item_id], f"{item_id} 히스토리 수집")
except Exception as e:
self.log(f"클릭 처리 중 오류: {e}", "ERROR")
def show_context_menu(self, event):
item = self.tree.identify_row(event.y)
if item:
self.tree.selection_set(item)
self.context_menu.post(event.x_root, event.y_root)
def run_process_thread(self, cmd_list, description):
"""백그라운드 스레드에서 서브프로세스 실행"""
def task():
self.status_label.config(text=f"{description} 중...", foreground="orange")
self.log(f"{description} 시작...", "INFO")
try:
# python 실행 경로 확보
python_exe = sys.executable
# 가상환경 venv/temp_env 사용 시 경로 조정
venv_python = os.path.join(project_root, "venv", "Scripts", "python.exe")
temp_env_python = os.path.join(current_dir, "temp_env", "Scripts", "python.exe")
if os.path.exists(temp_env_python):
python_exe = temp_env_python
elif os.path.exists(venv_python):
python_exe = venv_python
full_cmd = [python_exe] + cmd_list
# 서브프로세스 실행
process = subprocess.Popen(
full_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=current_dir,
text=True,
encoding='utf-8',
errors='replace' # 인코딩 에러 방지
)
stdout, stderr = process.communicate()
if stdout:
for line in stdout.splitlines():
if "Error" in line or "fail" in line.lower():
self.log(line, "ERROR")
else:
self.log(line, "INFO")
if stderr:
self.log(f"STDERR: {stderr}", "WARNING")
if process.returncode == 0:
self.log(f"{description} 완료 ✅", "SUCCESS")
else:
self.log(f"{description} 실패 (Exit Code: {process.returncode})", "ERROR")
except Exception as e:
self.log(f"실행 오류: {e}", "ERROR")
finally:
self.root.after(0, self.refresh_monitor)
self.root.after(0, lambda: self.status_label.config(text="🟢 시스템 대기중", foreground="green"))
thread = threading.Thread(target=task)
thread.daemon = True
thread.start()
def run_all_crawlers(self):
"""전체 통합 크롤링 실행 (강제 모드)"""
if messagebox.askyesno("확인", "모든 발전소 데이터를 강제로 수집하시겠습니까?"):
self.run_process_thread(["main.py", "--force"], "전체 데이터 수집")
def run_selected_crawler(self):
"""선택된 단일 사이트 크롤링 (현재 main.py는 단일 실행 옵션이 없어서 전체를 돌리되, 추후 개선 필요)"""
# 임시로 단일 실행 기능이 없으므로 알림만 띄움 (추후 main.py에 --site 옵션 추가 필요)
selected = self.tree.selection()
if not selected:
return
site_id = selected[0]
# main.py 수정 없이 특정 사이트만 돌리기 어려우므로, 안내 메시지
# 실제로는 main.py에 인자 처리를 추가해야 함.
# 여기서는 전체 실행으로 대체하거나, 추후 main.py 업데이트 후 구현
# 임시 구현: main.py를 호출하되 필터링은 구현 안 되어있음.
# 이번 단계에서는 GUI 틀을 만드는 것이므로 전체 실행으로 트리거
self.log(f"'{site_id}' 단일 실행 요청 (현재는 전체 실행으로 동작)", "WARNING")
self.run_process_thread(["main.py", "--force"], f"'{site_id}' 데이터 수집")
def run_daily_summary(self):
"""일일 통계 집계 실행"""
self.run_process_thread(["daily_summary.py"], "일일 통계 집계")
def show_site_logs(self):
selected = self.tree.selection()
if selected:
site_id = selected[0]
self.log(f"'{site_id}' 로그 조회 기능은 아직 구현되지 않았습니다.", "INFO")
def reset_learning_mode(self):
selected = self.tree.selection()
if selected:
site_id = selected[0]
if self.manager.reset_to_learning(site_id):
self.log(f"'{site_id}' 학습 모드로 리셋 완료", "SUCCESS")
self.refresh_monitor()
if __name__ == "__main__":
root = tk.Tk()
# 아이콘 설정 (옵션)
# try: root.iconbitmap("icon.ico")
# except: pass
app = CrawlerControlPanel(root)
root.mainloop()

View File

@ -1,456 +0,0 @@
# ==========================================
# crawler_manager.py - 크롤링 스케줄 최적화 미들웨어
# ==========================================
# NAS 리소스 절약을 위해 SQLite 기반으로 각 사이트의
# 업데이트 패턴을 학습하고, 데이터가 실제로 변경된 시점에만 DB 저장
#
# [설계 원칙]
# - 크롤링(HTTP 요청) 자체는 항상 허용 (야간 제외)
# → 원격 서버가 언제 업데이트할지 모르므로 주기적으로 확인해야 함
# - DB 저장은 데이터가 실제로 변경되었을 때만 실행
# → 중복 저장 방지 + NAS I/O 절약
# - 업데이트 패턴 학습은 부가 기능 (로깅용)
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
class CrawlerManager:
"""
크롤링 DB 저장을 최적화하는 매니저 클래스
- should_run: 야간(21~05) 여부만 체크 False면 크롤링 자체를 스킵
- should_save: 데이터가 실제로 변경되었는지 확인 False면 DB 저장 스킵
- analyze_and_optimize: 업데이트 패턴 학습 (로깅/모니터링 목적)
"""
def __init__(self, db_path: str = None):
"""
DB 연결 테이블 초기화
Args:
db_path: SQLite DB 파일 경로. 기본값은 스크립트와 같은 디렉토리의 crawler_manager.db
"""
if db_path is None:
db_path = Path(__file__).parent / "crawler_manager.db"
self.db_path = str(db_path)
self._init_db()
def _init_db(self):
"""테이블이 없으면 생성"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.executescript("""
CREATE TABLE IF NOT EXISTS site_rules (
site_id TEXT PRIMARY KEY,
status TEXT DEFAULT 'LEARNING',
target_minute INTEGER DEFAULT -1,
start_date TEXT,
last_run TEXT
);
CREATE TABLE IF NOT EXISTS site_data (
site_id TEXT PRIMARY KEY,
kw REAL,
today_kwh REAL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS update_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id TEXT,
detected_minute INTEGER,
detected_at TEXT
);
""")
conn.commit()
def _get_connection(self) -> sqlite3.Connection:
"""SQLite 연결 반환 (타임아웃 설정 추가)"""
return sqlite3.connect(self.db_path, timeout=10.0)
def _cleanup_old_history(self):
"""오래된 히스토리 정리 (30일 이상 지난 데이터 삭제)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
limit_date = (datetime.now() - timedelta(days=30)).isoformat()
cursor.execute("DELETE FROM update_history WHERE detected_at < ?", (limit_date,))
conn.commit()
except Exception as e:
print(f"⚠️ [CrawlerManager] 히스토리 정리 실패: {e}")
def register_site(self, site_id: str) -> bool:
"""
새로운 사이트 등록
Args:
site_id: 사이트 식별자 (: 'nrems-01')
Returns:
bool: 새로 등록되었으면 True, 이미 존재하면 False
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM site_rules WHERE site_id = ?", (site_id,))
if cursor.fetchone():
return False
today = datetime.now().strftime("%Y-%m-%d")
cursor.execute("""
INSERT INTO site_rules (site_id, status, target_minute, start_date, last_run)
VALUES (?, 'LEARNING', -1, ?, NULL)
""", (site_id, today))
conn.commit()
print(f" 📝 [CrawlerManager] '{site_id}' 신규 등록 (LEARNING 모드)")
return True
def should_run(self, site_id: str) -> bool:
"""
현재 시점에 해당 사이트를 크롤링(HTTP 요청)해야 하는지 판단.
[변경 사항]
이전: OPTIMIZED 상태면 특정 (minute) 윈도우에서만 크롤링 허용
문제: 원격 서버 업데이트 시점을 놓쳐 시계열 데이터 누락
현재: 야간(21~05)에만 False 반환, 외에는 항상 크롤링 허용
DB 저장 여부는 should_save()에서 별도 결정
Args:
site_id: 사이트 식별자
Returns:
bool: 크롤링 실행 여부 (야간이면 False)
"""
now = datetime.now()
current_hour = now.hour
current_minute = now.minute
# 야간 모드: 21시 ~ 05시에는 크롤링 중지 (발전 없는 시간대)
if current_hour >= 21 or current_hour < 5:
return False
# 히스토리 정리 (05시 정각에 1회)
if current_minute == 0 and current_hour == 5:
self._cleanup_old_history()
# 사이트 등록 (미등록 사이트 자동 등록)
self.register_site(site_id)
# 항상 크롤링 허용 (데이터 변경 여부는 should_save에서 판단)
return True
def should_save(self, site_id: str, current_data: dict) -> bool:
"""
수집한 데이터를 DB에 저장해야 하는지 판단.
원격 서버의 데이터가 이전 수집 시점과 달라졌을 때만 True 반환.
이를 통해 중복 저장을 방지하고 NAS I/O를 절약.
[저장 조건]
- today_kwh(금일 발전량) 증가했을 : 반드시 저장 (핵심 지표)
- kw(현재 출력) 변했을 : 저장 (실시간 상태 반영)
- 마지막 저장 1시간 이상 경과했을 : 강제 저장 (heartbeat)
데이터가 정체돼도 최소 1시간에 1번은 기록 보장
Args:
site_id: 사이트 식별자
current_data: {'kw': float, 'today': float}
Returns:
bool: DB에 저장해야 하면 True
"""
new_kw = float(current_data.get('kw', 0))
new_today = float(current_data.get('today', 0))
now = datetime.now()
with self._get_connection() as conn:
cursor = conn.cursor()
# 이전 데이터 조회
cursor.execute(
"SELECT kw, today_kwh, updated_at FROM site_data WHERE site_id = ?",
(site_id,)
)
row = cursor.fetchone()
should_save = False
if not row:
# 첫 수집 → 반드시 저장
should_save = True
else:
last_kw, last_today, last_updated_at = row
# 1. 금일 발전량이 증가했으면 저장
if new_today - last_today > 0.001:
should_save = True
# 2. 현재 출력(kW)이 변했으면 저장
elif abs(new_kw - last_kw) > 0.001:
should_save = True
# 3. 1시간 이상 저장 없었으면 강제 heartbeat 저장
elif last_updated_at:
try:
last_dt = datetime.fromisoformat(last_updated_at)
if now - last_dt >= timedelta(hours=1):
should_save = True
except (ValueError, TypeError):
should_save = True
if should_save:
# 현재 상태를 캐시에 업데이트
cursor.execute("""
INSERT INTO site_data (site_id, kw, today_kwh, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(site_id) DO UPDATE SET
kw = excluded.kw,
today_kwh = excluded.today_kwh,
updated_at = excluded.updated_at
""", (site_id, new_kw, new_today, now.isoformat()))
conn.commit()
return should_save
def check_data_change(self, site_id: str, current_data: dict) -> bool:
"""
[하위 호환용] should_save의 별칭.
기존 main.py 코드와의 호환성을 위해 유지.
내부적으로 should_save를 호출하며, 패턴 분석도 함께 수행.
"""
return self.should_save(site_id, current_data)
def analyze_and_optimize(self, site_id: str):
"""
업데이트 패턴 분석 기록 (모니터링/로깅 목적).
데이터 변경이 감지되었을 호출하여 원격 서버의 업데이트 패턴을 학습.
정보는 현재 크롤링 스케줄 제어에는 사용하지 않으며,
향후 분석이나 시각화를 위한 참고 데이터로만 활용.
"""
now = datetime.now()
current_minute = now.minute
# 히스토리 기록
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO update_history (site_id, detected_minute, detected_at)
VALUES (?, ?, ?)
""", (site_id, current_minute, now.isoformat()))
# 최근 기록 조회 (최대 5개)
cursor.execute("""
SELECT detected_minute
FROM update_history
WHERE site_id = ?
ORDER BY id DESC
LIMIT 5
""", (site_id,))
minutes = [r[0] for r in cursor.fetchall()]
conn.commit()
# 패턴 분석 (최소 3회 이상 데이터 필요)
if len(minutes) < 3:
return
recent = minutes[:3]
avg = sum(recent) / len(recent)
# 최대 편차가 5분 이내면 패턴 안정 (참고 정보로만 기록)
is_consistent = all(abs(m - avg) <= 5 for m in recent)
if is_consistent:
target = int(avg)
# 스케줄 제어에는 사용하지 않지만, 상태 기록은 유지 (모니터링용)
self._record_pattern(site_id, target)
else:
print(f" 📊 [CrawlerManager] '{site_id}' 패턴 분석 중... 최근: {recent}")
def _record_pattern(self, site_id: str, detected_minute: int):
"""
감지된 업데이트 패턴을 DB에 기록 (모니터링용).
크롤링 스케줄 제어에는 영향을 주지 않음.
"""
if not 0 <= detected_minute <= 59:
return
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT status, target_minute FROM site_rules WHERE site_id = ?", (site_id,))
row = cursor.fetchone()
if row and row[0] == 'OPTIMIZED' and abs(row[1] - detected_minute) <= 2:
return # 이미 동일한 패턴 기록됨
cursor.execute("""
UPDATE site_rules
SET status = 'OPTIMIZED', target_minute = ?
WHERE site_id = ?
""", (detected_minute, site_id))
conn.commit()
if cursor.rowcount > 0:
print(f" 📌 [CrawlerManager] '{site_id}' 업데이트 패턴 감지: 매시 {detected_minute}분 경 (참고용)")
def update_optimization(self, site_id: str, detected_minute: int) -> bool:
"""
[하위 호환용] 패턴 기록 메서드.
내부적으로 _record_pattern을 호출.
"""
self._record_pattern(site_id, detected_minute)
return True
def record_run(self, site_id: str):
"""
크롤링 성공 마지막 실행 시간 기록
Args:
site_id: 사이트 식별자
"""
now_str = datetime.now().isoformat()
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE site_rules
SET last_run = ?
WHERE site_id = ?
""", (now_str, site_id))
conn.commit()
def get_site_info(self, site_id: str) -> dict:
"""
사이트 정보 조회 (디버깅/모니터링용)
Args:
site_id: 사이트 식별자
Returns:
dict: 사이트 정보 또는 None
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT site_id, status, target_minute, start_date, last_run
FROM site_rules
WHERE site_id = ?
""", (site_id,))
row = cursor.fetchone()
if row:
return {
"site_id": row[0],
"status": row[1],
"target_minute": row[2],
"start_date": row[3],
"last_run": row[4]
}
return None
def get_all_sites(self) -> list:
"""
모든 사이트 정보 조회
Returns:
list: 모든 사이트 정보 리스트
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT site_id, status, target_minute, start_date, last_run
FROM site_rules
ORDER BY site_id
""")
rows = cursor.fetchall()
return [
{
"site_id": row[0],
"status": row[1],
"target_minute": row[2],
"start_date": row[3],
"last_run": row[4]
}
for row in rows
]
def reset_to_learning(self, site_id: str) -> bool:
"""
사이트를 다시 LEARNING 상태로 리셋
Args:
site_id: 사이트 식별자
Returns:
bool: 리셋 성공 여부
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE site_rules
SET status = 'LEARNING', target_minute = -1
WHERE site_id = ?
""", (site_id,))
conn.commit()
return cursor.rowcount > 0
# ==========================================
# main.py 연동 방식 (변경 없음 - 하위 호환 유지)
# ==========================================
#
# main.py에서의 사용 흐름:
#
# 1. should_run(site_id)
# → 야간이면 False (크롤링 자체 스킵)
# → 그 외에는 항상 True (항상 HTTP 요청)
#
# 2. 크롤링(HTTP 요청) 실행
#
# 3. record_run(item_id) ← 크롤링 성공 기록
#
# 4. check_data_change(item_id, item) ← should_save와 동일
# → True: 데이터 변경됨 → DB 저장 진행
# → False: 변경 없음 → DB 저장 스킵
#
# 5. analyze_and_optimize(item_id) ← 패턴 학습 (선택적)
#
# ==========================================
# Cron 설정 (10분마다 실행 권장)
# ==========================================
# */10 * * * * cd /volume1/dev/SolorPower/crawler && \
# /volume1/dev/SolorPower/crawler/venv/bin/python main.py >> cron.log 2>&1
# ==========================================
if __name__ == "__main__":
manager = CrawlerManager()
print("=== CrawlerManager 테스트 ===\n")
test_sites = ["nrems-01", "nrems-02", "kremc-05"]
for site_id in test_sites:
manager.register_site(site_id)
print("\n[등록된 사이트]")
for site in manager.get_all_sites():
print(f" {site['site_id']}: {site['status']} (target: {site['target_minute']}분)")
print("\n[should_run 테스트]")
for site_id in test_sites:
result = manager.should_run(site_id)
print(f" {site_id}: {'✅ 실행' if result else '⏭️ 스킵 (야간)'}")
print("\n[should_save 테스트]")
test_data = {'kw': 15.5, 'today': 120.0}
for site_id in test_sites:
result = manager.should_save(site_id, test_data)
print(f" {site_id}: {'✅ 저장' if result else '⏭️ 스킵 (변경 없음)'}")
print("\n=== 테스트 완료 ===")

View File

@ -1,110 +0,0 @@
# Crawler 시스템 파일 구조 및 역할 정의
이 문서는 `crawler` 폴더 내의 각 파일과 모듈의 역할, 기능, 그리고 상호 작용 방식에 대해 자세히 설명합니다.
## 📁 디렉토리 구조 및 핵심 파일 요약
| 파일명 | 분류 | 핵심 역할 |
|---|---|---|
| **main.py** | Core | 크롤러 시스템의 메인 진입점. 전체 수집 프로세스 조율 |
| **config.py** | Config | 발전소 정보, 비밀번호, 시스템 상수 등 설정 관리 |
| **database.py** | Data | Supabase 데이터베이스 연결 및 CRUD 처리 |
| **crawler_manager.py** | Logic | 지능형 스케줄링 관리 (업데이트 패턴 학습 및 최적화) |
| **crawler_gui.py** | UI | 관리자용 대시보드 (윈도우 GUI), 모니터링 및 수동 제어 |
| **daily_summary.py** | Batch | 일일 발전 통계 집계 및 요약 테이블 저장 |
| **fetch_history.py** | Tool | 과거 데이터(Hourly, Daily) 수집 도구 |
| **sync_plants.py** | Tool | 발전소 메타 정보를 DB와 동기화 |
| **verify_data.py** | Test | 수집된 데이터의 무결성 검증 및 테스트 스크립트 |
---
## 📄 파일별 상세 역할 분석
### 1. 핵심 시스템 (Core System)
#### `main.py`
* **역할**: 전체 크롤링 시스템의 오케스트레이터(Orchestrator).
* **주요 기능**:
* `integrated_monitoring()` 함수를 통해 정의된 모든 발전소를 순회합니다.
* `CrawlerManager`를 통해 현재 시점에 실행해야 할 크롤러를 선별합니다.
* 각 발전소 타입에 맞는 크롤러 함수(`crawlers` 패키지)를 동적으로 호출합니다.
* 수집된 실시간 데이터를 콘솔에 출력하고, `database.py`를 통해 DB에 저장합니다.
* 발전량이 0인 경우 등 간단한 이상 감지 로직을 수행합니다.
* **실행 방식**: 스케줄러(Cron 등)에 의해 주기적으로 실행되거나, GUI에서 호출됩니다. `--force` 옵션으로 강제 실행 가능합니다.
#### `config.py`
* **역할**: 시스템 설정 및 발전소 정보의 단일 진실 공급원(Single Source of Truth).
* **주요 기능**:
* `SYSTEM_CONSTANTS`: 각 크롤러 시스템(NREMS, KREMC 등)의 URL 및 API 엔드포인트 정의.
* `COMPANIES`: 업체 및 산하 발전소들의 계층 구조, 인증 정보(ID/PW), 용량(Customer ID) 등을 JSON 구조로 관리.
* `get_all_plants()`: 계층화된 데이터를 크롤러가 사용하기 쉬운 평탄화(Flat)된 리스트로 변환하여 제공.
* **특이 사항**: 보안이 필요한 인증 정보가 포함되어 있어 관리에 주의가 필요합니다. 1, 2호기와 같이 하나의 계정으로 분리되는 발전소(`is_split`) 설정도 이곳에서 관리됩니다.
#### `crawler_manager.py` (Smart Scheduler)
* **역할**: 비효율적인 반복 호출을 줄이고 NAS 리소스를 절약하기 위한 미들웨어.
* **주요 기능**:
* **SQLite 기반 상태 관리**: `crawler_manager.db` 로컬 파일에 각 발전소의 상태 저장.
* **학습 모드(LEARNING)**: 초기에는 자주 실행하며 발전소 서버의 데이터 업데이트 주기 패턴을 학습.
* **최적화 모드(OPTIMIZED)**: 학습된 업데이트 시점(예: 매시 15분) 전후의 윈도우(Window)에만 크롤링을 허용.
* 야간(21시~05시) 크롤링 자동 차단 로직 포함.
### 2. 데이터 관리 (Data Management)
#### `database.py`
* **역할**: Supabase 클라우드 데이터베이스와의 인터페이스.
* **주요 기능**:
* Supabase 클라이언트 싱글턴 연결 관리.
* `save_to_supabase()`: 실시간 발전 데이터(`solar_logs`) 저장. 일일 통계(`daily_stats`) 단순 Upsert 처리.
* `save_history()`: 과거 내역 저장 시 사용되며, `solar_logs`(Hourly), `daily_stats`(Daily), `monthly_stats`(Monthly) 등 데이터 타입에 따라 적절한 테이블에 저장하고, 월별 통계 자동 갱신 트리거 로직을 포함합니다.
#### `daily_summary.py`
* **역할**: 수집된 로그 데이터를 기반으로 일일 최종 통계를 확정 짓는 배치 스크립트.
* **주요 기능**:
* 특정 날짜의 `solar_logs`를 모두 조회하여 발전소별 총 발전량, 피크 출력, 발전 시간(이용률)을 계산.
* 계산된 확정 데이터를 `daily_stats` 테이블에 저장.
* 주로 하루가 끝나는 시점이나 다음 날 새벽에 실행하여 데이터 정확도를 보정합니다.
### 3. 사용자 인터페이스 (User Interface)
#### `crawler_gui.py`
* **역할**: 윈도우 환경에서 크롤러 상태를 시각적으로 모니터링하고 제어하는 관리자 도구.
* **주요 기능**:
* `tkinter` 기반의 GUI 제공.
* 발전소별 현재 상태(대기, 실행중, 최적화 여부), 마지막 실행 시간 등을 트리 뷰(Tree View)로 표시.
* 개별/전체 크롤링 강제 실행, 히스토리 수집 명령, 학습 모드 리셋 등의 제어 기능 제공.
* 실시간 로그 창을 통해 백그라운드 프로세스(`subprocess`)의 실행 결과를 출력.
### 4. 도구 및 유틸리티 (Tools & Utilities)
#### `fetch_history.py`
* **역할**: 누락된 데이터나 초기 구축 시 과거 데이터를 수집하기 위한 스크립트.
* **주요 기능**:
* 특정 발전소 ID를 인자로 받아 과거 데이터를 조회.
* 각 크롤러 모듈(`crawlers/`)에 구현된 `fetch_history_hourly`, `fetch_history_daily` 등을 호출.
* 시간별(Hourly), 일별(Daily) 데이터를 수집하여 DB에 적재.
#### `sync_plants.py`
* **역할**: 로컬 코드(`config.py`)와 원격 DB(`plants` 테이블) 간의 메타 데이터 동기화.
* **주요 기능**:
* 새로운 발전소가 추가되거나 이름/용량이 변경되었을 때, `config.py`의 내용을 DB의 마스터 테이블에 반영(Upsert).
* NREMS 1, 2호기와 같이 논리적으로 분리해야 하는 발전소를 별도 레코드로 DB에 생성.
#### `verify_data.py`
* **역할**: 크롤링 로직 검증 및 데이터 무결성 테스트.
* **주요 기능**:
* 각 발전소별로 샘플 날짜(과거/현재)를 지정하여 실제 데이터를 가져와 봅니다.
* 시간별, 일별, 월별 합계가 논리적으로 맞는지 검증 포맷을 출력하여 개발자가 확인하기 쉽게 돕습니다.
### 5. 하위 폴더
#### `crawlers/` (폴더)
* **역할**: 실제 사이트별 크롤링 로직이 구현된 모듈들의 집합.
* **구성**:
* `nrems.py`, `kremc.py`, `hyundai.py`, `sun_wms.py`, `cmsolar.py` 등 사이트 타입별로 파일이 존재.
* 각 모듈은 공통적으로 `get_current_status()` (실시간), `fetch_history_*` (과거 내역) 등의 인터페이스를 구현해야 함.
#### `venv/`, `temp_env/` (폴더)
* **역할**: Python 가상 환경 폴더. 프로젝트 실행에 필요한 라이브러리(`requests`, `pandas`, `supabase` 등)가 설치됨.
---
*작성일: 2026-01-28*

View File

@ -1,20 +0,0 @@
# crawlers 패키지 초기화
from .nrems import fetch_data as fetch_nrems
from .kremc import fetch_data as fetch_kremc
from .sun_wms import fetch_data as fetch_sunwms
from .hyundai import fetch_data as fetch_hyundai
from .cmsolar import fetch_data as fetch_cmsolar
# 크롤러 타입별 매핑
CRAWLER_MAP = {
'nrems': fetch_nrems,
'kremc': fetch_kremc,
'sun_wms': fetch_sunwms,
'hyundai': fetch_hyundai,
'cmsolar': fetch_cmsolar
}
def get_crawler(crawler_type):
"""크롤러 타입에 해당하는 fetch 함수 반환"""
return CRAWLER_MAP.get(crawler_type)

View File

@ -1,115 +0,0 @@
# ==========================================
# crawlers/base.py - 크롤러 공통 유틸리티
# ==========================================
import requests
def safe_float(value):
"""
안전한 float 변환
None, 문자열, 콤마 포함 숫자 등을 처리
"""
if value is None:
return 0.0
try:
return float(str(value).replace(',', ''))
except (ValueError, TypeError):
return 0.0
def create_session():
"""기본 설정된 requests 세션 생성"""
import os
import sys
# 상위 경로의 config.py 불러오기 처리
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from config import USE_PROXY, PROXIES
except ImportError:
USE_PROXY = False
PROXIES = None
session = requests.Session()
if USE_PROXY and PROXIES:
session.proxies.update(PROXIES)
return session
def get_default_headers():
"""기본 HTTP 헤더 반환"""
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*'
}
def determine_status(current_kw):
"""발전량 기반 상태 결정"""
if current_kw > 0:
return "🟢 정상"
else:
return "💤 대기"
def format_result(name, kw, today, plant_id, status=None):
"""결과 딕셔너리 포맷 통일"""
if status is None:
status = determine_status(kw)
return {
'name': name,
'kw': kw,
'today': today,
'id': plant_id,
'status': status
}
def validate_data_quality(data_list, value_key='generation_kwh'):
"""
데이터 품질 검증
Returns:
dict: {
'is_valid': bool,
'warnings': list,
'all_zero': bool,
'duplicate_ratio': float
}
"""
if not data_list or len(data_list) == 0:
return {
'is_valid': False,
'warnings': ['데이터 없음'],
'all_zero': True,
'duplicate_ratio': 0.0
}
warnings = []
values = [safe_float(item.get(value_key, 0)) for item in data_list]
# 모두 0인 경우 체크
all_zero = all(v == 0 for v in values)
if all_zero:
warnings.append('모든 값이 0 - 실제 데이터가 아닐 가능성')
# 연속 중복 체크
if len(values) > 1:
duplicates = 0
for i in range(len(values) - 1):
if values[i] == values[i+1]:
duplicates += 1
duplicate_ratio = duplicates / (len(values) - 1)
if duplicate_ratio > 0.8:
warnings.append(f'연속 중복 비율 {duplicate_ratio*100:.1f}% - 실제 데이터가 아닐 가능성')
else:
duplicate_ratio = 0.0
is_valid = not all_zero and duplicate_ratio < 0.8
return {
'is_valid': is_valid,
'warnings': warnings,
'all_zero': all_zero,
'duplicate_ratio': duplicate_ratio
}

View File

@ -1,512 +0,0 @@
# ==========================================
# crawlers/cmsolar.py - CMSolar 크롤러 (10호기)
# HTML 테이블 파싱 방식
# ==========================================
import requests
import re
from .base import create_session, safe_float
def fetch_data(plant_info):
"""
CMSolar 발전소 데이터 수집
"""
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '함안햇빛발전소')
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
data_url = system.get('data_url', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded'
}
# 로그인
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
return []
# Site selection (Required for idx_ok.php)
base_url = system.get('base_url', 'http://www.cmsolar2.kr')
change_url = f"{base_url}/change.php?site={site_no}"
session.get(change_url, headers=headers)
except Exception as e:
print(f"{plant_name} 접속 에러: {e}")
return []
# 데이터 요청 (JSON Endpoint)
target_url = f"{base_url}/plant/sub/idx_ok.php?mode=getPlant"
try:
res = session.get(target_url, headers=headers)
if res.status_code == 200:
# Handle potential encoding issues if needed, though requests usually guesses well
if res.encoding is None:
res.encoding = 'utf-8'
data = res.json()
# Parsing logic for [{"plant": {...}}] structure
if isinstance(data, list) and len(data) > 0:
plant_data = data[0].get('plant', {})
# Unit Conversion: W -> kW
curr_kw = safe_float(plant_data.get('now', 0)) / 1000.0
today_kwh = safe_float(plant_data.get('today', 0)) / 1000.0
# Status check
is_error = int(plant_data.get('inv_error', 0))
status = "🟢 정상" if is_error == 0 else "🔴 점검/고장"
# 0kW during day is suspicious but night is normal.
# If needed, override status based on time, but sticking to error flag is safer.
if curr_kw == 0 and status == "🟢 정상":
# Optional: Check if night time?
pass
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': curr_kw,
'today': today_kwh,
'status': status
}]
else:
print(f"{plant_name} 데이터 형식 오류: {data}")
return []
else:
return []
except Exception as e:
print(f"{plant_name} 에러: {e}")
return []
def fetch_history_hourly(plant_info, start_date, end_date):
"""
CMSolar 발전소의 시간대별 과거 데이터 수집
실제 엔드포인트: /plant/sub/report_ok.php (HTML 테이블 응답)
파라미터: mode=getPowers&type=daily&device=total&start=YYYY-MM-DD&money=
"""
from datetime import datetime, timedelta
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
# 실제 데이터 엔드포인트
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
data_url = f"{base_url}/plant/sub/report_ok.php"
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 사이트 선택 (필수!)
try:
change_url = f"{base_url}/change.php?site={site_no}"
session.get(change_url, headers=headers)
print(" ✓ Site selected")
except Exception as e:
print(f" ✗ Site selection error: {e}")
return results
# 날짜 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
# 실제 확인된 시간별 엔드포인트 (type=daily는 하루 치 시간별 데이터 반환)
params = {
'mode': 'getPowers',
'type': 'daily',
'device': 'total',
'start': date_str,
'money': ''
}
try:
res = session.get(data_url, params=params, headers=headers, timeout=10)
res.encoding = 'utf-8'
if res.status_code == 200:
# HTML 테이블 파싱
html = res.text
# <tbody> 안의 <tr> 태그 찾기
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
if tbody_match:
tbody_content = tbody_match.group(1)
# 각 <tr> 파싱 (시간과 발전량)
# <tr class="odd"><td>9</td><td>3.0</td>...
tr_pattern = r'<tr[^>]*>\s*<td>(\d+)</td>\s*<td>([\d.]+)</td>'
matches = re.findall(tr_pattern, tbody_content)
if matches:
print(f" ✓ Found {len(matches)} hourly records for {date_str}")
for hour, kwh in matches:
generation_kwh = safe_float(kwh)
timestamp = f"{date_str} {hour.zfill(2)}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': 0
})
else:
print(f" ⚠ No data for {date_str}")
else:
print(f" ⚠ No tbody found for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error for {date_str}: {e}")
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results
def fetch_history_daily(plant_info, start_date, end_date):
"""
CMSolar 발전소의 일별 과거 데이터 수집
실제 엔드포인트: /plant/sub/report_ok.php (HTML 테이블 응답)
파라미터: mode=getPowers&type=month&device=total&start=YYYY-MM-01&money=
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
# 실제 데이터 엔드포인트
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
data_url = f"{base_url}/plant/sub/report_ok.php"
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar Daily] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 사이트 선택 (필수!)
try:
change_url = f"{base_url}/change.php?site={site_no}"
session.get(change_url, headers=headers)
print(" ✓ Site selected")
except Exception as e:
print(f" ✗ Site selection error: {e}")
return results
# 월 단위로 반복 (type=month는 한 달 치 일별 데이터 반환)
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
month_start = current_date.strftime('%Y-%m-01')
year = current_date.year
month = current_date.month
# 실제 확인된 일별 엔드포인트 (type=month)
params = {
'mode': 'getPowers',
'type': 'month',
'device': 'total',
'start': month_start,
'money': ''
}
try:
res = session.get(data_url, params=params, headers=headers, timeout=10)
res.encoding = 'utf-8'
if res.status_code == 200:
# HTML 테이블 파싱
html = res.text
# <tbody> 안의 <tr> 태그 찾기
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
if tbody_match:
tbody_content = tbody_match.group(1)
# 각 <tr> 파싱 (날짜와 발전량)
# <tr class="odd"><td>1</td><td>136.00</td>...
tr_pattern = r'<tr[^>]*>\s*<td>(\d+)</td>\s*<td>([\d.,]+)</td>'
matches = re.findall(tr_pattern, tbody_content)
if matches:
print(f" ✓ Found {len(matches)} daily records for {month_start[:7]}")
for day, kwh in matches:
# 쉼표 제거
kwh_clean = kwh.replace(',', '')
generation_kwh = safe_float(kwh_clean)
date_str = f"{year:04d}-{month:02d}-{int(day):02d}"
# 날짜 범위 필터링
if date_str >= start_date and date_str <= end_date:
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': generation_kwh,
'current_kw': 0
})
print(f"{date_str}: {generation_kwh:.2f}kWh")
else:
print(f" ⚠ No tbody found for {month_start[:7]}")
else:
print(f" ✗ HTTP {res.status_code} for {month_start[:7]}")
except Exception as e:
print(f" ✗ Error for {month_start[:7]}: {e}")
# 다음 달로 이동
current_date = (current_date.replace(day=1) + relativedelta(months=1))
print(f"[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
CMSolar 발전소의 월별 과거 데이터 수집
실제 엔드포인트: /plant/sub/report_ok.php (HTML 테이블 응답)
파라미터: mode=getPowers&type=year&device=total&start=YYYY-01-01&money=
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
# 시작일자 체크
plant_start_date = plant_info.get('start_date', '2020-08-31')
plant_start_month = plant_start_date[:7] # YYYY-MM
# 실제 시작 월은 발전소 가동일 이후로 제한
if start_month < plant_start_month:
actual_start = plant_start_month
print(f" 발전소 가동일({plant_start_date}) 이후부터 수집: {actual_start}")
else:
actual_start = start_month
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
# 실제 데이터 엔드포인트
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
data_url = f"{base_url}/plant/sub/report_ok.php"
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar Monthly] {plant_name} ({actual_start} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 사이트 선택 (필수!)
try:
change_url = f"{base_url}/change.php?site={site_no}"
session.get(change_url, headers=headers)
print(" ✓ Site selected")
except Exception as e:
print(f" ✗ Site selection error: {e}")
return results
# 연도별로 반복 (type=year는 한 해 치 월별 데이터 반환)
current_month = datetime.strptime(actual_start, '%Y-%m')
end_month_dt = datetime.strptime(end_month, '%Y-%m')
processed_years = set()
while current_month <= end_month_dt:
year = current_month.year
# 이미 처리한 연도는 스킵
if year in processed_years:
current_month += relativedelta(months=1)
continue
processed_years.add(year)
year_start = f"{year}-01-01"
# 실제 확인된 월별 엔드포인트 (type=year)
params = {
'mode': 'getPowers',
'type': 'year',
'device': 'total',
'start': year_start,
'money': ''
}
try:
res = session.get(data_url, params=params, headers=headers, timeout=10)
res.encoding = 'utf-8'
if res.status_code == 200:
# HTML 테이블 파싱
html = res.text
# <tbody> 안의 <tr> 태그 찾기
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
if tbody_match:
tbody_content = tbody_match.group(1)
# 각 <tr> 파싱 (월과 발전량)
# <tr class="even"><td>1</td><td>2,836.00</td>...
tr_pattern = r'<tr[^>]*>\s*<td>(\d+)</td>\s*<td>([\d.,]+)</td>'
matches = re.findall(tr_pattern, tbody_content)
if matches:
year_count = 0
for month, kwh in matches:
# 쉼표 제거
kwh_clean = kwh.replace(',', '')
generation_kwh = safe_float(kwh_clean)
month_str = f"{year:04d}-{int(month):02d}"
# 월 범위 필터링
if month_str >= actual_start and month_str <= end_month:
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': generation_kwh
})
print(f"{month_str}: {generation_kwh:.1f}kWh")
year_count += 1
if year_count > 0:
print(f" → Collected {year_count} months from {year}")
else:
print(f" ⚠ No tbody found for year {year}")
else:
print(f" ✗ HTTP {res.status_code} for year {year}")
except Exception as e:
print(f" ✗ Error for year {year}: {e}")
# 다음 연도로 이동
current_month = current_month.replace(year=year+1, month=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results

View File

@ -1,319 +0,0 @@
# ==========================================
# crawlers/cmsolar.py - CMSolar 크롤러 (10호기)
# ==========================================
import requests
from .base import create_session
def fetch_data(plant_info):
"""
CMSolar 발전소 데이터 수집
"""
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
base_url = system.get('base_url', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/143.0.0.0 Safari/537.36',
'Referer': f'{base_url}/plant/index.php'
}
# 1. 로그인
try:
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
except:
return []
# 2. 사이트 선택
try:
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
except:
return []
# 3. 데이터 요청
target_url = f"{base_url}/plant/sub/idx_ok.php?mode=getPlant"
try:
res = session.get(target_url, headers=headers)
res.encoding = 'utf-8'
data = res.json()
plant_data = data[0]['plant']
# 단위 변환 (W -> kW, Wh -> kWh)
curr_kw = float(plant_data.get('now', 0)) / 1000
today_kwh = float(plant_data.get('today', 0)) / 1000
is_error = int(plant_data.get('inv_error', 0))
status = "🟢 정상" if is_error == 0 else "🔴 점검/고장"
print(f" [CMSolar] {plant_name} 수집 완료: {round(curr_kw, 2)} kW")
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': round(curr_kw, 2),
'today': round(today_kwh, 2),
'status': status
}]
except Exception as e:
print(f"{plant_name} 에러: {e}")
return []
def fetch_history_daily(plant_info, start_date, end_date):
"""
CMSolar 발전소의 일별 과거 데이터 수집
"""
from datetime import datetime, timedelta
from .base import safe_float
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar Daily] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Referer': f'{base_url}/plant/index.php'
}
try:
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
print(" ✓ Login successful")
except Exception as e:
print(f" ✗ Login failed: {e}")
return results
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
# 일별 데이터 엔드포인트 (추정)
daily_url = f"{base_url}/plant/sub/daily_data.php?date={date_str}"
try:
res = session.get(daily_url, headers=headers, timeout=10)
res.encoding = 'utf-8'
if res.status_code == 200:
data = res.json()
daily_kwh = safe_float(data.get('today', data.get('daily', 0))) / 1000.0
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': daily_kwh
})
print(f"{date_str}: {daily_kwh}kWh")
except Exception as e:
print(f"{date_str}: {e}")
current_date += timedelta(days=1)
print(f"[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
CMSolar 발전소의 월별 과거 데이터 수집
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
from .base import safe_float
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar Monthly] {plant_name} ({start_month} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Referer': f'{base_url}/plant/index.php'
}
try:
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
print(" ✓ Login successful")
except Exception as e:
print(f" ✗ Login failed: {e}")
return results
current_month = datetime.strptime(start_month, '%Y-%m')
end_month_dt = datetime.strptime(end_month, '%Y-%m')
while current_month <= end_month_dt:
month_str = current_month.strftime('%Y-%m')
# 월별 데이터 엔드포인트 (추정)
monthly_url = f"{base_url}/plant/sub/monthly_data.php?month={month_str}"
try:
res = session.get(monthly_url, headers=headers, timeout=10)
res.encoding = 'utf-8'
if res.status_code == 200:
data = res.json()
monthly_kwh = safe_float(data.get('month', data.get('monthly', 0))) / 1000.0
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': monthly_kwh
})
print(f"{month_str}: {monthly_kwh}kWh")
except Exception as e:
print(f"{month_str}: {e}")
current_month += relativedelta(months=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results
def fetch_history_hourly(plant_info, start_date, end_date):
"""
CMSolar 발전소의 시간대별 과거 데이터 수집
Args:
plant_info: dict, 발전소 정보
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
Returns:
list: 시간대별 데이터 레코드
"""
from datetime import datetime, timedelta
from .base import safe_float
results = []
# 설정 추출
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/143.0.0.0 Safari/537.36',
'Referer': f'{base_url}/plant/index.php'
}
try:
login_data = {'id': login_id, 'pw': login_pw, 'commit': 'Login'}
session.post(f"{base_url}/login_ok.php", data=login_data, headers=headers)
# 사이트 선택
session.get(f"{base_url}/change.php?site={site_no}", headers=headers)
print(f" ✓ Login successful")
except Exception as e:
print(f" ✗ Login failed: {e}")
return results
# 날짜 범위 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
print(f"\n[Processing Date] {date_str}")
# 시간대별 데이터 엔드포인트 (추정)
hourly_url = f"{base_url}/plant/sub/hourly_data.php?site={site_no}&date={date_str}"
try:
res = session.get(hourly_url, headers=headers, timeout=10)
res.encoding = 'utf-8'
if res.status_code == 200:
data = res.json()
hourly_data = data if isinstance(data, list) else data.get('hourly', [])
if hourly_data and len(hourly_data) > 0:
print(f" ✓ Found {len(hourly_data)} hourly records")
for item in hourly_data:
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
generation_wh = safe_float(item.get('energy', item.get('now', 0)))
generation_kwh = generation_wh / 1000.0 if generation_wh > 1000 else generation_wh
current_kw = safe_float(item.get('power', 0)) / 1000.0
timestamp = f"{date_str} {hour}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': current_kw
})
else:
print(f" ⚠ No hourly data for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
# 다음 날짜로
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results

View File

@ -1,427 +0,0 @@
# ==========================================
# crawlers/cmsolar.py - CMSolar 크롤러 (10호기)
# ==========================================
import requests
from .base import create_session, safe_float
def fetch_data(plant_info):
"""
CMSolar 발전소 데이터 수집
"""
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '함안햇빛발전소')
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('payload_id', '')
login_pw = auth.get('payload_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
data_url = system.get('data_url', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded'
}
# 로그인
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
return []
except Exception as e:
print(f"{plant_name} 접속 에러: {e}")
return []
# 데이터 요청
try:
res = session.get(data_url, headers=headers)
if res.status_code == 200:
data = res.json()
curr_kw = safe_float(data.get('current', data.get('power', 0)))
today_kwh = safe_float(data.get('today', data.get('generation', 0)))
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': curr_kw,
'today': today_kwh,
'status': status
}]
else:
return []
except Exception as e:
print(f"{plant_name} 에러: {e}")
return []
def fetch_history_hourly(plant_info, start_date, end_date):
"""
CMSolar 발전소의 시간대별 과거 데이터 수집
실제 엔드포인트: /plant/sub/report_ok.php
파라미터: mode=getPowers&type=daily&device=total&start=YYYY-MM-DD&money=
"""
from datetime import datetime, timedelta
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('payload_id', '')
login_pw = auth.get('payload_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
# 실제 데이터 엔드포인트
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
data_url = f"{base_url}/plant/sub/report_ok.php"
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 사이트 선택 (필수!)
try:
change_url = f"{base_url}/change.php?site={site_no}"
session.get(change_url, headers=headers)
print(" ✓ Site selected")
except Exception as e:
print(f" ✗ Site selection error: {e}")
return results
# 날짜 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
# 실제 확인된 시간별 엔드포인트 (type=daily는 하루 치 시간별 데이터 반환)
params = {
'mode': 'getPowers',
'type': 'daily',
'device': 'total',
'start': date_str,
'money': ''
}
try:
res = session.get(data_url, params=params, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
# 시간별 데이터 파싱
hourly_data = data.get('data', []) or data.get('list', []) or data.get('powers', [])
if isinstance(hourly_data, list) and len(hourly_data) > 0:
print(f" ✓ Found {len(hourly_data)} hourly records for {date_str}")
for item in hourly_data:
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
generation_kwh = safe_float(item.get('power', item.get('generation', item.get('kwh', 0))))
current_kw = safe_float(item.get('kw', 0))
timestamp = f"{date_str} {hour}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': current_kw
})
else:
print(f" ⚠ No data for {date_str}")
else:
print(f" ✗ HTTP {res.status_code} for {date_str}")
except Exception as e:
print(f" ✗ Error: {e}")
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results
def fetch_history_daily(plant_info, start_date, end_date):
"""
CMSolar 발전소의 일별 과거 데이터 수집
실제 엔드포인트: /plant/sub/report_ok.php
파라미터: mode=getPowers&type=month&device=total&start=YYYY-MM-DD&money=
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('payload_id', '')
login_pw = auth.get('payload_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
# 실제 데이터 엔드포인트
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
data_url = f"{base_url}/plant/sub/report_ok.php"
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar Daily] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 사이트 선택 (필수!)
try:
change_url = f"{base_url}/change.php?site={site_no}"
session.get(change_url, headers=headers)
print(" ✓ Site selected")
except Exception as e:
print(f" ✗ Site selection error: {e}")
return results
# 월 단위로 반복 (type=month는 한 달 치 일별 데이터 반환)
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
month_start = current_date.strftime('%Y-%m-01')
# 실제 확인된 일별 엔드포인트 (type=month)
params = {
'mode': 'getPowers',
'type': 'month',
'device': 'total',
'start': month_start,
'money': ''
}
try:
res = session.get(data_url, params=params, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
# 일별 데이터 파싱
daily_data = data.get('data', []) or data.get('list', []) or data.get('powers', [])
if isinstance(daily_data, list) and len(daily_data) > 0:
print(f" ✓ Found {len(daily_data)} daily records for {month_start[:7]}")
for item in daily_data:
date_str = item.get('date', item.get('day', ''))
generation_kwh = safe_float(item.get('power', item.get('generation', item.get('kwh', 0))))
current_kw = safe_float(item.get('kw', 0))
# 날짜 범위 필터링
if date_str >= start_date and date_str <= end_date:
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': generation_kwh,
'current_kw': current_kw
})
print(f"{date_str}: {generation_kwh:.2f}kWh")
else:
print(f" ✗ HTTP {res.status_code} for {month_start[:7]}")
except Exception as e:
print(f" ✗ Error: {e}")
# 다음 달로 이동
current_date = (current_date.replace(day=1) + relativedelta(months=1))
print(f"[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
CMSolar 발전소의 월별 과거 데이터 수집
실제 엔드포인트: /plant/sub/report_ok.php
파라미터: mode=getPowers&type=year&device=total&start=YYYY-MM-DD&money=
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
results = []
plant_id = plant_info.get('id', 'cmsolar-10')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '10호기')
login_id = auth.get('payload_id', '')
login_pw = auth.get('payload_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
# 실제 데이터 엔드포인트
base_url = system.get('api_url', 'http://www.cmsolar2.kr')
data_url = f"{base_url}/plant/sub/report_ok.php"
session = create_session()
print(f"\n{'='*60}")
print(f"[CMSolar Monthly] {plant_name} ({start_month} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 사이트 선택 (필수!)
try:
change_url = f"{base_url}/change.php?site={site_no}"
session.get(change_url, headers=headers)
print(" ✓ Site selected")
except Exception as e:
print(f" ✗ Site selection error: {e}")
return results
# 연도별로 반복 (type=year는 한 해 치 월별 데이터 반환)
current_date = datetime.strptime(start_month + '-01', '%Y-%m-%d')
end_date = datetime.strptime(end_month + '-01', '%Y-%m-%d')
years_processed = set()
while current_date <= end_date:
year_start = current_date.strftime('%Y-01-01')
year = current_date.year
# 중복 연도 스킵
if year in years_processed:
current_date += relativedelta(months=1)
continue
years_processed.add(year)
# 실제 확인된 월별 엔드포인트 (type=year)
params = {
'mode': 'getPowers',
'type': 'year',
'device': 'total',
'start': year_start,
'money': ''
}
try:
res = session.get(data_url, params=params, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
# 월별 데이터 파싱
monthly_data = data.get('data', []) or data.get('list', []) or data.get('powers', [])
if isinstance(monthly_data, list) and len(monthly_data) > 0:
print(f" ✓ Found {len(monthly_data)} monthly records for {year}")
for item in monthly_data:
month_str = item.get('month', item.get('date', ''))
generation_kwh = safe_float(item.get('power', item.get('generation', item.get('kwh', 0))))
# YYYY-MM 형식으로 정규화
if len(month_str) >= 7:
month_str = month_str[:7]
# 월 범위 필터링
if month_str >= start_month and month_str <= end_month:
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': generation_kwh
})
print(f"{month_str}: {generation_kwh:.1f}kWh")
else:
print(f" ✗ HTTP {res.status_code} for {year}")
except Exception as e:
print(f" ✗ Error: {e}")
current_date += relativedelta(months=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results

View File

@ -1,489 +0,0 @@
# ==========================================
# crawlers/hyundai.py - 현대 크롤러 (8호기)
# ==========================================
import requests
from .base import create_session
def fetch_data(plant_info):
"""
현대 발전소 데이터 수집 (Hi-Smart 3.0)
"""
plant_id = plant_info.get('id', 'hyundai-08')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '8호기')
user_id = auth.get('user_id', '')
password = auth.get('password', '')
site_id = auth.get('site_id', '')
base_url = system.get('base_url', '')
login_path = system.get('login_path', '')
data_path = system.get('data_path', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/json;charset=UTF-8',
'Accept': 'application/json, text/plain, */*',
'Origin': base_url,
'Referer': f'{base_url}/',
'X-ApiVersion': 'v1.0',
'X-App': 'HIWAY4VUETIFY',
'X-CallType': '0',
'X-Channel': 'WEB_PC',
'X-Lang': 'ko',
'X-Mid': 'login',
'X-VName': 'UI'
}
# 로그인
login_urls = [
f"{base_url}{login_path}",
f"{base_url}{login_path}.json",
f"{base_url}{login_path}.do"
]
login_success = False
for url in login_urls:
try:
payload = {"user_id": user_id, "password": password}
res = session.post(url, json=payload, headers=headers)
if res.status_code == 200:
auth_token = res.headers.get('x-auth-token')
if auth_token:
headers['x-auth-token'] = auth_token
print(f" [현대] 로그인 성공 & 토큰 확보!")
login_success = True
break
except Exception:
continue
if not login_success:
print(f"❌ 현대 {plant_name} 로그인 실패")
return []
# 데이터 요청
try:
data_url = f"{base_url}{data_path}"
params = {'site_id': site_id}
# 데이터 요청용 헤더 업데이트
headers['X-Channel'] = 'WEB_PCWeb'
headers['X-Mid'] = 'siteWork'
res = session.get(data_url, params=params, headers=headers)
if res.status_code != 200:
print(f"❌ 현대 데이터 요청 실패 (코드: {res.status_code})")
return []
data = res.json()
if 'datas' in data and 'unitedSiteInfo' in data['datas']:
info = data['datas']['unitedSiteInfo']
curr_kw = float(info.get('PVPCS_Pac', '0').replace(',', ''))
today_kwh = float(info.get('PVPCS_Daily_P', '0').replace(',', ''))
print(f" [현대] {plant_name} 데이터: {curr_kw}kW / {today_kwh}kWh")
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': curr_kw,
'today': today_kwh,
'status': "🟢 정상" if curr_kw > 0 else "💤 대기"
}]
else:
print(f"⚠️ 현대 데이터 구조가 다릅니다.")
return []
except Exception as e:
print(f"❌ 현대 파싱 에러: {e}")
return []
def fetch_history_hourly(plant_info, start_date, end_date):
"""
현대 발전소의 시간대별 과거 데이터 수집
Args:
plant_info: {
'id': 'hyundai-08',
'name': '8호기',
'type': 'hyundai',
'auth': {'user_id': '...', 'password': '...', 'site_id': '...'},
'system': {'base_url': '...', 'login_path': '...', 'data_path': '...'},
'company_name': '태양과바람'
}
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
Returns:
list: [{
'plant_id': 'hyundai-08',
'timestamp': '2026-01-15 14:00:00',
'generation_kwh': 123.5,
'current_kw': 15.2
}, ...]
"""
from datetime import datetime, timedelta
from .base import safe_float
results = []
# 설정 추출
plant_id = plant_info.get('id', 'hyundai-08')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '8호기')
user_id = auth.get('user_id', '')
password = auth.get('password', '')
site_id = auth.get('site_id', '')
base_url = system.get('base_url', '')
login_path = system.get('login_path', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Hyundai History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/json;charset=UTF-8',
'Accept': 'application/json, text/plain, */*',
'Origin': base_url,
'Referer': f'{base_url}/',
'X-ApiVersion': 'v1.0',
'X-App': 'HIWAY4VUETIFY',
'X-CallType': '0',
'X-Channel': 'WEB_PC',
'X-Lang': 'ko',
'X-Mid': 'login',
'X-VName': 'UI'
}
login_urls = [
f"{base_url}{login_path}",
f"{base_url}{login_path}.json",
f"{base_url}{login_path}.do"
]
login_success = False
for url in login_urls:
try:
payload = {"user_id": user_id, "password": password}
res = session.post(url, json=payload, headers=headers)
if res.status_code == 200:
auth_token = res.headers.get('x-auth-token')
if auth_token:
headers['x-auth-token'] = auth_token
print(f" ✓ Login successful")
login_success = True
break
except Exception:
continue
if not login_success:
print(f" ✗ Login failed")
return results
# 날짜 범위 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
headers['X-Mid'] = 'siteWork'
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
print(f"\n[Processing Date] {date_str}")
# getSolraDayWork 엔드포인트 사용 (20분 간격 데이터)
url = f"{base_url}/hismart/site/getSolraDayWork"
params = {
'site_id': site_id,
'startDate': date_str # YYYY-MM-DD 형식
}
try:
res = session.get(url, params=params, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
# solraDayWork 구조 파싱
day_work = data.get('datas', {}).get('solraDayWork', {})
run_data = day_work.get('runData', [])
run_time = day_work.get('runTime', [])
if run_data and run_time and len(run_data) == len(run_time):
print(f" ✓ Found {len(run_data)} records (20-min intervals)")
# runData와 runTime을 조합하여 시간대별 데이터 생성
for i in range(len(run_data)):
time_str = run_time[i] # "14:20" 형식
generation_kw = safe_float(run_data[i]) # kW 값
# timestamp 생성
timestamp = f"{date_str} {time_str}:00"
# 20분 간격 데이터를 그대로 저장 (또는 시간 단위로 집계 가능)
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kw, # 실제로는 순간 kW값
'current_kw': generation_kw
})
print(f" → Collected {len(run_data)} records")
else:
print(f" ⚠ No data for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
# 다음 날짜로
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} records")
print(f"{'='*60}\n")
return results
def fetch_history_daily(plant_info, start_date, end_date):
"""
현대 발전소의 일별 과거 데이터 수집 ( 단위 최적화)
getSolraMonthWork API를 사용하여 달치 일별 데이터를 번에 가져옴
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
from .base import safe_float
import calendar
results = []
plant_id = plant_info.get('id', 'hyundai-08')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '8호기')
user_id = auth.get('user_id', '')
password = auth.get('password', '')
site_id = auth.get('site_id', '')
base_url = system.get('base_url', '')
login_path = system.get('login_path', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Hyundai Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/json;charset=UTF-8',
'X-ApiVersion': 'v1.0',
'X-App': 'HIWAY4VUETIFY',
'X-Channel': 'WEB_PC',
'X-Lang': 'ko',
'X-Mid': 'login',
'X-VName': 'UI'
}
login_url = f"{base_url}{login_path}"
payload = {"user_id": user_id, "password": password}
try:
res = session.post(login_url, json=payload, headers=headers)
auth_token = res.headers.get('x-auth-token')
if not auth_token:
print(" ✗ Login failed")
return results
headers['x-auth-token'] = auth_token
headers['X-Mid'] = 'siteWork'
print(" ✓ Login successful")
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 월 단위 반복
current_month = datetime.strptime(start_date[:7], '%Y-%m') # YYYY-MM-01
end_month_dt = datetime.strptime(end_date[:7], '%Y-%m')
while current_month <= end_month_dt:
month_str = current_month.strftime('%Y-%m')
year = current_month.year
month = current_month.month
print(f" [Fetching] {month_str} ...", end="", flush=True)
url = f"{base_url}/hismart/site/getSolraMonthWork"
params = {'site_id': site_id, 'month': month_str}
try:
res = session.get(url, params=params, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
day_work = data.get('datas', {}).get('solraMonthWork', {})
run_data = day_work.get('runData', [])
if run_data:
count = 0
for day_idx, val in enumerate(run_data):
day = day_idx + 1
daily_total = safe_float(val)
# 유효한 날짜인지 확인 (예: 2월 30일 방지)
try:
# 해당 월의 마지막 날짜 확인
last_day = calendar.monthrange(year, month)[1]
if day > last_day:
continue
date_str = f"{year}-{month:02d}-{day:02d}"
# 요청된 날짜 범위 내인지 확인
if date_str >= start_date and date_str <= end_date:
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': round(daily_total, 2)
})
count += 1
except ValueError:
continue
print(f" OK ({count} days)")
else:
print(f" No data")
else:
print(f" HTTP {res.status_code}")
except Exception as e:
print(f" Error: {e}")
current_month += relativedelta(months=1)
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
현대 발전소의 월별 과거 데이터 수집
Args:
plant_info: 발전소 정보
start_month: str, 시작월 (YYYY-MM)
end_month: str, 종료월 (YYYY-MM)
Returns:
list: [{'plant_id': '...', 'month': '2026-01', 'generation_kwh': 12345.6}, ...]
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
from .base import safe_float
results = []
plant_id = plant_info.get('id', 'hyundai-08')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '8호기')
user_id = auth.get('user_id', '')
password = auth.get('password', '')
site_id = auth.get('site_id', '')
base_url = system.get('base_url', '')
login_path = system.get('login_path', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Hyundai Monthly] {plant_name} ({start_month} ~ {end_month})")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/json;charset=UTF-8',
'X-ApiVersion': 'v1.0',
'X-App': 'HIWAY4VUETIFY',
'X-Channel': 'WEB_PC',
'X-Lang': 'ko',
'X-Mid': 'login',
'X-VName': 'UI'
}
login_url = f"{base_url}{login_path}"
payload = {"user_id": user_id, "password": password}
res = session.post(login_url, json=payload, headers=headers)
auth_token = res.headers.get('x-auth-token')
if not auth_token:
print(" ✗ Login failed")
return results
headers['x-auth-token'] = auth_token
headers['X-Mid'] = 'siteWork'
print(" ✓ Login successful")
current_month = datetime.strptime(start_month, '%Y-%m')
end_month_dt = datetime.strptime(end_month, '%Y-%m')
while current_month <= end_month_dt:
month_str = current_month.strftime('%Y-%m')
try:
# 실제 확인된 월별 엔드포인트: getSolraMonthWork
url = f"{base_url}/hismart/site/getSolraMonthWork"
params = {
'site_id': site_id,
'month': month_str # YYYY-MM 형식
}
res = session.get(url, params=params, headers=headers, verify=False, timeout=10)
if res.status_code == 200:
data = res.json()
# 응답 구조: datas.solraMonthWork.runData = 일별 발전량 배열
if 'datas' in data and 'solraMonthWork' in data['datas']:
month_data = data['datas']['solraMonthWork']
run_data = month_data.get('runData', [])
# runData는 해당 월의 일별 발전량 배열 → 합산
monthly_kwh = sum(run_data) if run_data else 0.0
print(f"{month_str}: {monthly_kwh:.1f}kWh (from {len(run_data)} days)")
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': monthly_kwh
})
except Exception as e:
print(f"{month_str}: {e}")
current_month += relativedelta(months=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results

View File

@ -1,559 +0,0 @@
# ==========================================
# crawlers/kremc.py - KREMC 크롤러 (5호기)
# ==========================================
import requests
import urllib.parse
from .base import safe_float, create_session
def fetch_data(plant_info):
"""
KREMC 발전소 데이터 수집
"""
# 설정 추출
plant_id = plant_info.get('id', 'kremc-05')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '5호기')
user_id = auth.get('user_id', '')
password = auth.get('password', '')
login_url = system.get('login_url', '')
api_base = system.get('api_base', '')
enso_type = system.get('enso_type', '15001')
try:
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
'Origin': 'https://kremc.kr',
'Referer': 'https://kremc.kr/login'
}
# 1. 로그인
login_data = {'userId': user_id, 'password': password}
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
if login_res.status_code != 200:
print(f" ⚠️ KREMC 로그인 실패: {login_res.status_code}")
return []
try:
login_json = login_res.json()
if login_json.get('status') == 200 or login_json.get('code') == 'S001':
data = login_json.get('data')
if isinstance(data, str) and len(data) > 10:
token = data
elif isinstance(data, dict):
token = data.get('token') or data.get('accessToken') or data.get('jwt')
if not token:
return []
else:
return []
else:
print(f" ⚠️ KREMC 로그인 실패: {login_json.get('message', 'Unknown')}")
return []
except:
return []
print(f" [KREMC] 토큰 획득 성공")
# 2. API 헤더 설정
api_headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'X-Auth-Token': token
}
installer_id_encoded = urllib.parse.quote(user_id)
# 3. 실시간 발전량 (kW)
latest_url = f"{api_base}/monitor/installer/gath/latest?installerId={installer_id_encoded}&ensoTypeCode={enso_type}"
latest_res = session.get(latest_url, headers=api_headers, timeout=10)
current_kw = 0.0
if latest_res.status_code == 200:
try:
latest_data = latest_res.json()
data = latest_data.get('data', {})
if isinstance(data, dict):
watts = safe_float(data.get('outpElcpFigr', 0))
current_kw = watts / 1000.0 if watts > 0 else 0.0
except:
pass
# 4. 일일 발전량 (kWh)
energy_url = f"{api_base}/monitor/installer/gath/energy?installerId={installer_id_encoded}&ensoTypeCode={enso_type}&cid="
energy_res = session.get(energy_url, headers=api_headers, timeout=10)
today_kwh = 0.0
if energy_res.status_code == 200:
try:
energy_data = energy_res.json()
data = energy_data.get('data', {})
if isinstance(data, dict):
today_kwh = safe_float(data.get('dayEnergy', 0))
except:
pass
print(f" [KREMC] {plant_name} 데이터: {current_kw} kW / {today_kwh} kWh")
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': current_kw,
'today': today_kwh,
'status': '🟢 정상' if current_kw > 0 else '💤 대기'
}]
except Exception as e:
print(f" ❌ KREMC 오류: {e}")
return []
def fetch_history_hourly(plant_info, start_date, end_date):
"""
KREMC 발전소의 시간대별 과거 데이터 수집
Args:
plant_info: dict, 발전소 정보
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
Returns:
list: 시간대별 데이터 레코드
"""
from datetime import datetime, timedelta
import urllib.parse
results = []
# 설정 추출
plant_id = plant_info.get('id', 'kremc-05')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
options = plant_info.get('options', {})
plant_name = plant_info.get('name', '5호기')
user_id = auth.get('user_id', '')
password = auth.get('password', '')
login_url = system.get('login_url', '')
api_base = system.get('api_base', '')
enso_type = system.get('enso_type', '15001')
# KREMC 추가 파라미터
cid = options.get('cid', '10013000376')
city_prov_code = options.get('cityProvCode', '11')
rgn_code = options.get('rgnCode', '11410')
dong_code = options.get('dongCode', '1141011700')
session = create_session()
print(f"\n{'='*60}")
print(f"[KREMC History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
'Origin': 'https://kremc.kr',
'Referer': 'https://kremc.kr/login'
}
try:
login_data = {'userId': user_id, 'password': password}
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
if login_res.status_code != 200:
print(f" ✗ Login failed: {login_res.status_code}")
return results
login_json = login_res.json()
if login_json.get('status') == 200 or login_json.get('code') == 'S001':
data = login_json.get('data')
if isinstance(data, str) and len(data) > 10:
token = data
elif isinstance(data, dict):
token = data.get('token') or data.get('accessToken') or data.get('jwt')
if not token:
print(f" ✗ Token not found")
return results
else:
print(f" ✗ Invalid login data")
return results
else:
print(f" ✗ Login failed: {login_json.get('message', 'Unknown')}")
return results
print(f" ✓ Login successful")
# API 헤더 설정
api_headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'X-Auth-Token': token
}
# 날짜 범위 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
print(f"\n[Processing Date] {date_str}")
# 실제 확인된 시간별 엔드포인트
hourly_url = f"{api_base}/stat/userbyuser/meainDataList"
params = {
'cid': cid,
'userId': user_id,
'cityProvCode': city_prov_code,
'rgnCode': rgn_code,
'dongCode': dong_code,
'dateType': 'HH',
'startGathDtm': date_str,
'endGathDtm': date_str,
'ensoTypeCode': enso_type
}
try:
res = session.get(hourly_url, params=params, headers=api_headers, timeout=10)
if res.status_code == 200:
data = res.json()
# KREMC 실제 응답 구조: data.userByTimeDataResultDtoList
hourly_list = data.get('data', {}).get('userByTimeDataResultDtoList', [])
if isinstance(hourly_list, list) and len(hourly_list) > 0:
print(f" ✓ Found {len(hourly_list)} hourly records")
for item in hourly_list:
# gathDtm: "00시", "01시", ..., "23시"
time_str = item.get('gathDtm', '')
hour = time_str.replace('', '').zfill(2)
generation_kwh = safe_float(item.get('dayEnergy', 0))
timestamp = f"{date_str} {hour}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': 0
})
else:
print(f" ⚠ No hourly data for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
# 다음 날짜로
current_date += timedelta(days=1)
except Exception as e:
print(f" ✗ Overall error: {e}")
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results
def fetch_history_daily(plant_info, start_date, end_date):
"""
KREMC 발전소의 일별 과거 데이터 수집 ( 단위 분할)
Args:
plant_info: 발전소 정보
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
"""
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import calendar
import urllib.parse
results = []
plant_id = plant_info.get('id', 'kremc-05')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
options = plant_info.get('options', {})
plant_name = plant_info.get('name', '5호기')
user_id = auth.get('user_id', '')
password = auth.get('password', '')
login_url = system.get('login_url', '')
api_base = system.get('api_base', '')
enso_type = system.get('enso_type', '15001')
# KREMC 추가 파라미터
cid = options.get('cid', '10013000376')
city_prov_code = options.get('cityProvCode', '11')
rgn_code = options.get('rgnCode', '11410')
dong_code = options.get('dongCode', '1141011700')
session = create_session()
print(f"\n{'='*60}")
print(f"[KREMC Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
try:
login_data = {'userId': user_id, 'password': password}
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
if login_res.status_code != 200:
print(" ✗ Login failed")
return results
login_json = login_res.json()
data = login_json.get('data')
token = data if isinstance(data, str) else data.get('token') if isinstance(data, dict) else None
if not token:
print(" ✗ Token not found")
return results
print(" ✓ Login successful")
api_headers = {
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json',
'X-Auth-Token': token
}
# 월 단위 루프 적용
current_date_dt = datetime.strptime(start_date, '%Y-%m-%d')
end_date_dt = datetime.strptime(end_date, '%Y-%m-%d')
# 시작하는 달의 첫날로 맞춤 (단, 실제 요청 시에는 start_date 고려)
# 하지만 그냥 편의상 start_date가 속한 달부터 end_date가 속한 달까지 루프 돌면서
# API 요청 범위를 정교하게 자르는 게 좋음.
# 루프용 변수: 현재 처리 중인 기간의 시작일
loop_start = current_date_dt
while loop_start <= end_date_dt:
# 현재 달의 마지막 날 계산
last_day_of_month = calendar.monthrange(loop_start.year, loop_start.month)[1]
loop_end = loop_start.replace(day=last_day_of_month)
# 종료일이 전체 종료일보다 뒤면 조정
if loop_end > end_date_dt:
loop_end = end_date_dt
s_str = loop_start.strftime('%Y-%m-%d')
e_str = loop_end.strftime('%Y-%m-%d')
print(f" [Fetching] {s_str} ~ {e_str} ...", end="", flush=True)
try:
daily_url = f"{api_base}/stat/userbyuser/meainDataList"
params = {
'cid': cid,
'userId': user_id,
'cityProvCode': city_prov_code,
'rgnCode': rgn_code,
'dongCode': dong_code,
'dateType': 'DD',
'startGathDtm': s_str,
'endGathDtm': e_str,
'ensoTypeCode': enso_type
}
res = session.get(daily_url, params=params, headers=api_headers, timeout=15)
if res.status_code == 200:
data = res.json()
daily_list = data.get('data', {}).get('userByTimeDataResultDtoList', [])
if daily_list:
count = 0
for item in daily_list:
# gathDtm: "2026-01-01" 형식
date_str = item.get('gathDtm', '')
generation_kwh = safe_float(item.get('dayEnergy', 0))
# 날짜 문자열 정리 (혹시 모를 공백 등 제거)
date_str = date_str.strip()
if len(date_str) > 10:
date_str = date_str[:10]
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': generation_kwh,
'current_kw': 0
})
count += 1
print(f" OK ({count} days)")
else:
print(" No data")
else:
print(f" HTTP {res.status_code}")
except Exception as e:
print(f" Error: {e}")
# 다음 기간 설정 (현재 기간 끝 다음날)
loop_start = loop_end + timedelta(days=1)
except Exception as e:
print(f" ✗ Overall Error: {e}")
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
KREMC 발전소의 월별 과거 데이터 수집
KREMC는 dateType=MM을 지원하지 않음 (500 에러)
일별 데이터(dateType=DD) 월별로 집계
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
import urllib.parse
results = []
plant_id = plant_info.get('id', 'kremc-05')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
options = plant_info.get('options', {})
plant_name = plant_info.get('name', '5호기')
# 시작일자 체크
plant_start_date = plant_info.get('start_date', '2018-06-28')
plant_start_month = plant_start_date[:7] # YYYY-MM
# 실제 시작 월은 발전소 가동일 이후로 제한
if start_month < plant_start_month:
actual_start = plant_start_month
print(f" 발전소 가동일({plant_start_date}) 이후부터 수집: {actual_start}")
else:
actual_start = start_month
user_id = auth.get('user_id', '')
password = auth.get('password', '')
login_url = system.get('login_url', '')
api_base = system.get('api_base', '')
enso_type = system.get('enso_type', '15001')
# KREMC 추가 파라미터
cid = options.get('cid', '10013000376')
city_prov_code = options.get('cityProvCode', '11')
rgn_code = options.get('rgnCode', '11410')
dong_code = options.get('dongCode', '1141011700')
session = create_session()
print(f"\n{'='*60}")
print(f"[KREMC Monthly] {plant_name} ({actual_start} ~ {end_month})")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
login_data = {'userId': user_id, 'password': password}
login_res = session.post(login_url, json=login_data, headers=headers, timeout=10)
if login_res.status_code != 200:
print(" ✗ Login failed")
return results
login_json = login_res.json()
data = login_json.get('data')
token = data if isinstance(data, str) else data.get('token') if isinstance(data, dict) else None
if not token:
print(" ✗ Token not found")
return results
print(" ✓ Login successful")
api_headers = {
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json',
'X-Auth-Token': token
}
current_month = datetime.strptime(actual_start, '%Y-%m')
end_month_dt = datetime.strptime(end_month, '%Y-%m')
while current_month <= end_month_dt:
month_str = current_month.strftime('%Y-%m')
# 해당 월의 시작일과 마지막일 계산
first_day = current_month.strftime('%Y-%m-01')
if current_month.month == 12:
last_day = current_month.replace(day=31).strftime('%Y-%m-%d')
else:
next_month = current_month + relativedelta(months=1)
last_day = (next_month - relativedelta(days=1)).strftime('%Y-%m-%d')
try:
# dateType=DD로 일별 데이터를 가져와서 합산
daily_url = f"{api_base}/stat/userbyuser/meainDataList"
params = {
'cid': cid,
'userId': user_id,
'cityProvCode': city_prov_code,
'rgnCode': rgn_code,
'dongCode': dong_code,
'dateType': 'DD',
'startGathDtm': first_day,
'endGathDtm': last_day,
'ensoTypeCode': enso_type
}
res = session.get(daily_url, params=params, headers=api_headers, timeout=10)
if res.status_code == 200:
data = res.json()
# KREMC 실제 응답 구조: data.userByTimeDataResultDtoList
daily_list = data.get('data', {}).get('userByTimeDataResultDtoList', [])
if isinstance(daily_list, list) and len(daily_list) > 0:
# 일별 데이터를 합산하여 월별 데이터 생성
monthly_total = sum([safe_float(item.get('dayEnergy', 0)) for item in daily_list])
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': monthly_total
})
print(f"{month_str}: {monthly_total:.1f}kWh (from {len(daily_list)} days)")
except Exception as e:
print(f" ✗ Error for {month_str}: {e}")
# 다음 달로
current_month += relativedelta(months=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results

View File

@ -1,618 +0,0 @@
# ==========================================
# crawlers/nrems.py - NREMS 크롤러 (1,2,3,4,9호기)
# ==========================================
import requests
import json
import re
from datetime import datetime
from .base import safe_float, create_session, format_result
def _get_inverter_sums(session, pscode, system_config):
"""
1, 2호기 인버터별 일일 발전량 추출 (JSON API 사용)
"""
try:
today_str = datetime.now().strftime('%Y-%m-%d')
month_str = datetime.now().strftime('%Y-%m')
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Referer': f'http://www.nrems.co.kr/v2/local/comp/cp_inv_time.php?pscode={pscode}'
}
data = {
'act': 'getList',
's_day': today_str,
's_date': today_str,
'e_date': today_str,
's_mon': month_str,
'e_mon': month_str,
'pscode': pscode,
'dispType': 'time'
}
inv_proc_url = system_config.get('inv_proc_url', '')
res = session.post(inv_proc_url, data=data, headers=headers, timeout=10)
if res.status_code == 200:
try:
json_data = res.json()
invlist = json_data.get('invlist', [])
sum_1 = 0.0
sum_2 = 0.0
for inv in invlist:
tidx = str(inv.get('tidx', ''))
sum_pw = safe_float(inv.get('sumPw'))
if tidx == '1':
sum_1 = sum_pw
elif tidx == '2':
sum_2 = sum_pw
if sum_1 > 0 or sum_2 > 0:
print(f" [API] 인버터 합계 추출 성공! (인버터1: {sum_1} kWh / 인버터2: {sum_2} kWh)")
return sum_1, sum_2
else:
print(f" ⚠️ API 응답에 인버터 데이터 없음")
return 0.0, 0.0
except json.JSONDecodeError:
print(f" ⚠️ JSON 파싱 실패")
return 0.0, 0.0
else:
print(f" ⚠️ API 응답 오류: {res.status_code}")
return 0.0, 0.0
except Exception as e:
print(f" [에러] {e}")
return 0.0, 0.0
def fetch_data(plant_info):
"""
NREMS 발전소 데이터 수집
Args:
plant_info: {
'id': 'nrems-03', # DB용 고유 ID (is_split인 경우 없음)
'name': '...',
'type': 'nrems',
'auth': {'pscode': '...'},
'options': {'is_split': True/False},
'system': {'api_url': '...', 'inv_proc_url': '...'},
'company_name': '...'
}
Returns:
list: [{'id': '...', 'name': '...', 'kw': 10.5, 'today': 100.0, 'status': '...'}]
"""
results = []
# 설정 추출
plant_id = plant_info.get('id', '') # DB용 고유 ID
pscode = plant_info['auth'].get('pscode', '')
is_split = plant_info['options'].get('is_split', False)
system_config = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
try:
# 메인 데이터 요청
api_url = system_config.get('api_url', '')
res = session.post(api_url, data={'pscode': pscode}, headers=headers, timeout=10)
if res.status_code != 200:
return results
try:
data = res.json()
except:
return results
# 데이터 찾기
ps_list = data.get('ps_status')
target_data = None
if isinstance(ps_list, list):
for item in ps_list:
code_in_res = item.get('pscode')
wmu_in_res = item.get('WMU_CODE')
# Case-insensitive comparison
if (code_in_res and code_in_res.lower() == pscode.lower()) or \
(wmu_in_res and wmu_in_res.lower() == pscode.lower()):
target_data = item
break
if not target_data and len(ps_list) > 0:
print(f" ⚠️ Target pscode '{pscode}' not found in response. Available: {[i.get('pscode') for i in ps_list]}")
target_data = ps_list[0] # Fallback
print(f" ⚠️ Using fallback: {target_data.get('pscode')}")
elif isinstance(ps_list, dict):
target_data = ps_list
if not target_data:
target_data = {}
total_kw = safe_float(target_data.get('KW'))
total_today = safe_float(target_data.get('TDayKWH'))
inverters = data.get('ivt_value', [])
# Case A: 1, 2호기 분리 처리
if is_split:
real_sum_1, real_sum_2 = _get_inverter_sums(session, pscode, system_config)
kw_1 = safe_float(inverters[0].get('KW')) if len(inverters) >= 1 else 0.0
kw_2 = safe_float(inverters[1].get('KW')) if len(inverters) >= 2 else 0.0
if (real_sum_1 + real_sum_2) > 0:
today_1 = real_sum_1
today_2 = real_sum_2
else:
print(" ⚠️ 백업 로직(비율) 가동")
inv_total = kw_1 + kw_2
if inv_total > 0:
today_1 = total_today * (kw_1 / inv_total)
today_2 = total_today * (kw_2 / inv_total)
else:
today_1 = total_today / 2
today_2 = total_today / 2
# [중요] 1, 2호기는 ID를 강제 지정
results.append({
'id': 'nrems-01', # 1호기 고정 ID
'name': f'{company_name} 1호기',
'kw': kw_1,
'today': round(today_1, 2),
'status': "🟢 정상" if kw_1 > 0 else "💤 대기"
})
results.append({
'id': 'nrems-02', # 2호기 고정 ID
'name': f'{company_name} 2호기',
'kw': kw_2,
'today': round(today_2, 2),
'status': "🟢 정상" if kw_2 > 0 else "💤 대기"
})
# Case B: 3, 4, 9호기
else:
results.append({
'id': plant_id, # config에서 정의된 ID 사용
'name': f'{company_name} {plant_name}',
'kw': total_kw,
'today': total_today,
'status': "🟢 정상" if total_kw > 0 else "💤 대기"
})
except Exception as e:
print(f"❌ NREMS {plant_name} 오류: {e}")
if not is_split:
results.append({
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': 0.0,
'today': 0.0,
'status': '🔴 오류'
})
return results
def fetch_history_hourly(plant_info, start_date, end_date):
"""
NREMS 발전소의 시간대별 과거 데이터 수집
Args:
plant_info: {
'id': 'nrems-03',
'name': '...',
'type': 'nrems',
'auth': {'pscode': '...'},
'options': {'is_split': True/False},
'system': {'api_url': '...', 'inv_proc_url': '...'},
'company_name': '...'
}
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
Returns:
list: [{
'plant_id': 'nrems-03',
'timestamp': '2026-01-15 14:00:00',
'generation_kwh': 123.5,
'current_kw': 15.2
}, ...]
"""
results = []
# 설정 추출
plant_id = plant_info.get('id', '')
pscode = plant_info['auth'].get('pscode', '')
is_split = plant_info['options'].get('is_split', False)
plant_name = plant_info.get('name', '')
# 날짜 범위 생성
from datetime import datetime, timedelta
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
session = create_session()
print(f"\n{'='*60}")
print(f"[NREMS Hourly] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
print(f"\n[Processing Date] {date_str}")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
}
try:
if is_split:
# 1,2호기: cp_inv_proc.php with dispType=time
url = 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/comp/cp_inv_time.php?pscode={pscode}'
payload = {
'act': 'getList',
's_day': date_str,
's_date': date_str,
'e_date': date_str,
's_mon': date_str[:7],
'e_mon': date_str[:7],
'pscode': pscode,
'dispType': 'time'
}
else:
# 3,4,9호기: pl_time_proc.php with act=empty
url = 'http://www.nrems.co.kr/v2/local/proc/pl_time_proc.php'
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/plant/pl_time.php?pscode={pscode}'
payload = {
'act': 'empty',
's_date': date_str,
'pscode': pscode
}
response = session.post(url, data=payload, headers=headers, timeout=10)
if response.status_code == 200:
data = response.json()
# 데이터 구조 확인
if is_split:
# 1,2호기: pwdata 키 사용
hourly_records = data.get('pwdata', [])
else:
# 3,4,9호기: pdata 키 사용
hourly_records = data.get('pdata', [])
if hourly_records:
print(f" ✓ Found {len(hourly_records)} hourly records")
for hour_data in hourly_records:
if is_split:
# 1,2호기: DATE, PW1, PW2
hour = hour_data.get('DATE', '00')
inv1_gen = safe_float(hour_data.get('PW1', 0))
inv2_gen = safe_float(hour_data.get('PW2', 0))
# timestamp 생성
timestamp = f"{date_str} {str(hour).zfill(2)}:00:00"
results.append({
'plant_id': 'nrems-01',
'timestamp': timestamp,
'generation_kwh': inv1_gen,
'current_kw': 0
})
results.append({
'plant_id': 'nrems-02',
'timestamp': timestamp,
'generation_kwh': inv2_gen,
'current_kw': 0
})
else:
# 3,4,9호기: TIME, INV
time_str = hour_data.get('TIME', '00:00')
hour = time_str.split(':')[0] # "14:00" -> "14"
generation_kwh = safe_float(hour_data.get('INV', 0))
# timestamp 생성
timestamp = f"{date_str} {str(hour).zfill(2)}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': 0
})
print(f" → Collected {len(hourly_records)} records")
else:
print(f" ⚠ No hourly data for {date_str}")
else:
print(f" ✗ HTTP {response.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results
def fetch_history_daily(plant_info, start_date, end_date):
"""
NREMS 발전소의 일별 과거 데이터 수집 ( 단위 루프)
Args:
plant_info: 발전소 정보
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
Returns:
list: [{'plant_id': '...', 'date': '2026-01-15', 'generation_kwh': 123.5}, ...]
"""
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import calendar
results = []
# 설정 추출
plant_id = plant_info.get('id', '')
pscode = plant_info['auth'].get('pscode', '')
is_split = plant_info['options'].get('is_split', False)
plant_name = plant_info.get('name', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[NREMS Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
print(f"{'='*60}")
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
current_dt = start_dt
while current_dt <= end_dt:
# 현재 처리할 달의 시작일과 종료일 계산
# 이번 달의 마지막 날
last_day_of_month = calendar.monthrange(current_dt.year, current_dt.month)[1]
chunk_end_dt = current_dt.replace(day=last_day_of_month)
# 요청 종료일이 전체 종료일보다 뒤면 전체 종료일로 제한
if chunk_end_dt > end_dt:
chunk_end_dt = end_dt
s_date_str = current_dt.strftime('%Y-%m-%d')
e_date_str = chunk_end_dt.strftime('%Y-%m-%d')
month_str = current_dt.strftime('%Y-%m')
print(f" [Fetching] {s_date_str} ~ {e_date_str} ...", end="", flush=True)
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
}
try:
if is_split:
# 1,2호기: cp_inv_proc.php with dispType=day
url = 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/comp/cp_inv_day.php?pscode={pscode}'
payload = {
'act': 'getList',
's_day': s_date_str, # s_day를 시작일로 변경
's_date': s_date_str,
'e_date': e_date_str,
's_mon': s_date_str[:7],
'e_mon': e_date_str[:7],
'pscode': pscode,
'dispType': 'day'
}
else:
# 3,4,9호기: pl_day_proc.php with s_day/e_day range
url = 'http://www.nrems.co.kr/v2/local/proc/pl_day_proc.php'
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/plant/pl_day.php?pscode={pscode}'
payload = {
'act': 'empty',
's_day': s_date_str,
'e_day': e_date_str,
'pscode': pscode
}
response = session.post(url, data=payload, headers=headers, timeout=15)
if response.status_code == 200:
try:
data = response.json()
# 데이터 구조 확인
if is_split:
daily_records = data.get('pwdata', [])
else:
daily_records = data.get('pdata', [])
if daily_records:
count = 0
for day_data in daily_records:
# 날짜 추출
date_raw = day_data.get('DATE', '')
if not date_raw:
continue
# 날짜 형식 변환: "12-28" -> "2025-12-28" 보정
clean_date = date_raw
if '-' in date_raw and len(date_raw.split('-')[0]) <= 2:
mm, dd = date_raw.split('-')
year = current_dt.year
# 만약 12월 데이터인데 1월에 긁으면... 루프 변수 current_dt.year 사용하면 안전
clean_date = f"{year}-{mm.zfill(2)}-{dd.zfill(2)}"
if is_split:
inv1_gen = safe_float(day_data.get('PW1', 0))
inv2_gen = safe_float(day_data.get('PW2', 0))
results.append({'plant_id': 'nrems-01', 'date': clean_date, 'generation_kwh': inv1_gen})
results.append({'plant_id': 'nrems-02', 'date': clean_date, 'generation_kwh': inv2_gen})
count += 1
else:
generation_kwh = safe_float(day_data.get('INV', 0))
results.append({'plant_id': plant_id, 'date': clean_date, 'generation_kwh': generation_kwh})
count += 1
print(f" OK ({count} days)")
else:
print(f" No data")
except Exception as json_err:
print(f" JSON Error: {json_err}")
else:
print(f" HTTP {response.status_code}")
except Exception as e:
print(f" Error: {e}")
# 다음 달 1일로 이동
current_dt = (current_dt.replace(day=1) + timedelta(days=32)).replace(day=1)
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
NREMS 발전소의 월별 과거 데이터 수집
Args:
plant_info: 발전소 정보
start_month: str, 시작월 (YYYY-MM)
end_month: str, 종료월 (YYYY-MM)
Returns:
list: [{'plant_id': '...', 'month': '2026-01', 'generation_kwh': 12345.6}, ...]
"""
from datetime import datetime
results = []
# 설정 추출
plant_id = plant_info.get('id', '')
pscode = plant_info['auth'].get('pscode', '')
is_split = plant_info['options'].get('is_split', False)
plant_name = plant_info.get('name', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[NREMS Monthly] {plant_name} ({start_month} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
}
try:
if is_split:
# 1,2호기: cp_inv_proc.php with dispType=mon
url = 'http://www.nrems.co.kr/v2/local/proc/cp_inv_proc.php'
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/comp/cp_inv_month.php?pscode={pscode}'
payload = {
'act': 'getList',
's_day': f"{end_month}-01",
's_date': f"{start_month}-01",
'e_date': f"{end_month}-01",
's_mon': start_month,
'e_mon': end_month,
'pscode': pscode,
'dispType': 'mon'
}
else:
# 3,4,9호기: pl_month_proc.php with s_date/e_date (YYYY-MM)
url = 'http://www.nrems.co.kr/v2/local/proc/pl_month_proc.php'
headers['Referer'] = f'http://www.nrems.co.kr/v2/local/plant/pl_month.php?pscode={pscode}'
payload = {
'act': 'empty',
's_date': start_month,
'e_date': end_month,
'pscode': pscode
}
response = session.post(url, data=payload, headers=headers, timeout=15)
if response.status_code == 200:
data = response.json()
# 데이터 구조 확인
if is_split:
# 1,2호기: pwdata 키 사용
monthly_records = data.get('pwdata', [])
else:
# 3,4,9호기: pdata 키 사용
monthly_records = data.get('pdata', [])
if monthly_records:
print(f" ✓ Found {len(monthly_records)} monthly records")
for month_data in monthly_records:
# 월 추출
month_str = month_data.get('DATE', '')
if not month_str:
continue
if is_split:
# 1,2호기: PW1, PW2 분리
inv1_gen = safe_float(month_data.get('PW1', 0))
inv2_gen = safe_float(month_data.get('PW2', 0))
results.append({
'plant_id': 'nrems-01',
'month': month_str,
'generation_kwh': inv1_gen
})
results.append({
'plant_id': 'nrems-02',
'month': month_str,
'generation_kwh': inv2_gen
})
print(f"{month_str}: Unit1={inv1_gen}kWh, Unit2={inv2_gen}kWh")
else:
# 3,4,9호기: INV 단일값
generation_kwh = safe_float(month_data.get('INV', 0))
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': generation_kwh
})
print(f"{month_str}: {generation_kwh}kWh")
print(f" → Collected {len(monthly_records)} records")
else:
print(f" ⚠ No monthly data found")
else:
print(f" ✗ HTTP {response.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
print(f"\n[Total] Collected {len(results)} monthly records\n")
return results

View File

@ -1,430 +0,0 @@
# ==========================================
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
# HTML 테이블 파싱 방식
# ==========================================
import requests
import re
import time
from .base import create_session, safe_float
def fetch_data(plant_info):
"""
Sun-WMS 발전소 데이터 수집
"""
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
data_url = system.get('data_url', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
# 1. 로그인
login_data = {
'act': 'loginChk',
'user_id': payload_id,
'user_pass': payload_pw
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
return []
except Exception as e:
print(f"{plant_name} 접속 에러: {e}")
return []
# 2. 데이터 요청
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
res.encoding = 'euc-kr'
content = res.text
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
today_kwh = float(match_today.group(1)) if match_today else 0.0
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': curr_kw,
'today': today_kwh,
'status': status
}]
except Exception as e:
print(f"{plant_name} 에러: {e}")
return []
def fetch_history_hourly(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 시간대별 과거 데이터 수집
실제 엔드포인트: /public/statics/statics.php (HTML 테이블 응답)
파라미터: tab01=0&tab02=1&tab03=2&tord=1&s_day=YYYY-MM-DD
"""
from datetime import datetime, timedelta
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
# base_url 추출
base_url = system.get('base_url', '')
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 날짜 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
# 실제 확인된 시간별 엔드포인트
params = {
'tab01': '0',
'tab02': '1',
'tab03': '2',
'tord': '1',
's_day': date_str
}
try:
res = session.get(statics_url, params=params, headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
# HTML 테이블 파싱
html = res.text
# <tbody> 안의 <tr> 태그 찾기
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
if tbody_match:
tbody_content = tbody_match.group(1)
# 각 <tr> 파싱
tr_pattern = r'<tr>\s*<td>(\d{2}):00</td>\s*<td>([\d.]+)</td>\s*</tr>'
matches = re.findall(tr_pattern, tbody_content)
if matches:
print(f" ✓ Found {len(matches)} hourly records")
for hour, kwh in matches:
generation_kwh = safe_float(kwh)
timestamp = f"{date_str} {hour}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': 0
})
else:
print(f" ⚠ No data for {date_str}")
else:
print(f" ⚠ No tbody found for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results
def fetch_history_daily(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 일별 과거 데이터 수집 ( 단위 분할)
실제 엔드포인트: /public/statics/statics.php (HTML 테이블 응답)
파라미터: tab01=0&tab02=2&tab03=2&tord=2&s_day=YYYY-MM-DD&e_day=YYYY-MM-DD
"""
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import calendar
import re
from .base import safe_float, create_session
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
base_url = system.get('base_url', '')
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date}) - Looping by Month")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
# 로그인
try:
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 월 단위 루프 적용
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
loop_start = start_dt
while loop_start <= end_dt:
# 현재 달의 마지막 날 계산
last_day_of_month = calendar.monthrange(loop_start.year, loop_start.month)[1]
loop_end = loop_start.replace(day=last_day_of_month)
# 종료일이 전체 종료일보다 뒤면 조정
if loop_end > end_dt:
loop_end = end_dt
s_str = loop_start.strftime('%Y-%m-%d')
e_str = loop_end.strftime('%Y-%m-%d')
print(f" [Fetching] {s_str} ~ {e_str} ...", end="", flush=True)
params = {
'tab01': '0',
'tab02': '2',
'tab03': '2',
'tord': '2',
's_day': s_str,
'e_day': e_str
}
try:
res = session.get(statics_url, params=params, headers=headers, timeout=15)
res.encoding = 'euc-kr'
if res.status_code == 200:
html = res.text
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
if tbody_match:
tbody_content = tbody_match.group(1)
tr_pattern = r'<tr>\s*<td>(\d{4}-\d{2}-\d{2})</td>\s*<td>([\d.]+)</td>'
matches = re.findall(tr_pattern, tbody_content)
if matches:
count = 0
for date_str, kwh in matches:
generation_kwh = safe_float(kwh)
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': generation_kwh,
'current_kw': 0
})
count += 1
print(f" OK ({count} days)")
else:
print(" No data")
else:
print(" No tbody")
else:
print(f" HTTP {res.status_code}")
except Exception as e:
print(f" Error: {e}")
# 다음 기간 설정
loop_start = loop_end + timedelta(days=1)
print(f"\n[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
Sun-WMS 발전소의 월별 과거 데이터 수집
실제 엔드포인트: /public/statics/statics.php (HTML 테이블 응답)
월별 데이터는 일별 데이터를 월별로 집계
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
# 시작일자 체크
plant_start_date = plant_info.get('start_date', '2019-12-30')
plant_start_month = plant_start_date[:7] # YYYY-MM
# 실제 시작 월은 발전소 가동일 이후로 제한
if start_month < plant_start_month:
actual_start = plant_start_month
print(f" 발전소 가동일({plant_start_date}) 이후부터 수집: {actual_start}")
else:
actual_start = start_month
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
# base_url 추출
base_url = system.get('base_url', '')
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Monthly] {plant_name} ({actual_start} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 월 단위로 반복
current_month = datetime.strptime(actual_start, '%Y-%m')
end_month_dt = datetime.strptime(end_month, '%Y-%m')
while current_month <= end_month_dt:
month_str = current_month.strftime('%Y-%m')
# 해당 월의 시작일과 마지막일
first_day = current_month.strftime('%Y-%m-01')
if current_month.month == 12:
last_day = current_month.replace(day=31).strftime('%Y-%m-%d')
else:
next_month = current_month + relativedelta(months=1)
last_day = (next_month - relativedelta(days=1)).strftime('%Y-%m-%d')
# 일별 엔드포인트로 한 달치 데이터 수집해서 합산
params = {
'tab01': '0',
'tab02': '2',
'tab03': '2',
'tord': '2',
's_day': first_day,
'e_day': last_day
}
try:
res = session.get(statics_url, params=params, headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
# HTML 테이블 파싱
html = res.text
# <tbody> 안의 <tr> 태그 찾기
tbody_match = re.search(r'<tbody>(.*?)</tbody>', html, re.DOTALL)
if tbody_match:
tbody_content = tbody_match.group(1)
# 각 <tr> 파싱 (날짜와 발전량)
tr_pattern = r'<tr>\s*<td>(\d{4}-\d{2}-\d{2})</td>\s*<td>([\d.]+)</td>'
matches = re.findall(tr_pattern, tbody_content)
if matches:
# 일별 데이터를 합산
monthly_total = sum([safe_float(kwh) for _, kwh in matches])
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': monthly_total
})
print(f"{month_str}: {monthly_total:.1f}kWh (from {len(matches)} days)")
else:
print(f" ⚠ No data for {month_str}")
except Exception as e:
print(f" ✗ Error for {month_str}: {e}")
# 다음 달로
current_month += relativedelta(months=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results

View File

@ -1,343 +0,0 @@
# ==========================================
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
# ==========================================
import requests
import re
import time
from .base import create_session
def fetch_data(plant_info):
"""
Sun-WMS 발전소 데이터 수집
"""
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
data_url = system.get('data_url', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
# 1. 로그인
login_data = {
'act': 'loginChk',
'user_id': payload_id,
'user_pass': payload_pw
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
return []
except Exception as e:
print(f"❌ {plant_name} 접속 에러: {e}")
return []
# 2. 데이터 요청
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
res.encoding = 'euc-kr'
content = res.text
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
today_kwh = float(match_today.group(1)) if match_today else 0.0
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': curr_kw,
'today': today_kwh,
'status': status
}]
except Exception as e:
print(f"❌ {plant_name} 에러: {e}")
return []
def fetch_history_daily(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 일별 과거 데이터 수집
"""
from datetime import datetime, timedelta
from .base import safe_float
import time
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
# 일별 데이터 엔드포인트 (추정)
daily_url = f"{base_url}/public/chart/getDailyData.php?date={date_str}"
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{daily_url}&time={timestamp}", headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
daily_kwh = safe_float(data.get('daily', data.get('today', 0)))
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': daily_kwh
})
print(f" ✓ {date_str}: {daily_kwh}kWh")
except Exception as e:
print(f" ✗ {date_str}: {e}")
current_date += timedelta(days=1)
print(f"[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
Sun-WMS 발전소의 월별 과거 데이터 수집
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
from .base import safe_float
import time
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Monthly] {plant_name} ({start_month} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
current_month = datetime.strptime(start_month, '%Y-%m')
end_month_dt = datetime.strptime(end_month, '%Y-%m')
while current_month <= end_month_dt:
month_str = current_month.strftime('%Y-%m')
# 월별 데이터 엔드포인트 (추정)
monthly_url = f"{base_url}/public/chart/getMonthlyData.php?month={month_str}"
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{monthly_url}&time={timestamp}", headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
monthly_kwh = safe_float(data.get('monthly', data.get('month', 0)))
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': monthly_kwh
})
print(f" ✓ {month_str}: {monthly_kwh}kWh")
except Exception as e:
print(f" ✗ {month_str}: {e}")
current_month += relativedelta(months=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results
def fetch_history_hourly(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 시간대별 과거 데이터 수집
Args:
plant_info: dict, 발전소 정보
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
Returns:
list: 시간대별 데이터 레코드
"""
from datetime import datetime, timedelta
from .base import safe_float
import time
results = []
# 설정 추출
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'act': 'loginChk',
'user_id': payload_id,
'user_pass': payload_pw
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
print(f" ✗ Login failed")
return results
print(f" ✓ Login successful")
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 날짜 범위 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
print(f"\n[Processing Date] {date_str}")
# 시간대별 데이터 엔드포인트 (추정)
hourly_url = f"{base_url}/public/chart/getHourlyData.php?date={date_str}"
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{hourly_url}&time={timestamp}", headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
hourly_data = data if isinstance(data, list) else data.get('hourly', [])
if hourly_data and len(hourly_data) > 0:
print(f" ✓ Found {len(hourly_data)} hourly records")
for item in hourly_data:
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
generation_kwh = safe_float(item.get('power', item.get('kwh', 0)))
current_kw = safe_float(item.get('kw', 0))
timestamp = f"{date_str} {hour}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': current_kw
})
else:
print(f" ⚠ No hourly data for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
# 다음 날짜로
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results

View File

@ -1,359 +0,0 @@
# ==========================================
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
# ==========================================
import requests
import re
import time
from .base import create_session, safe_float
def fetch_data(plant_info):
"""
Sun-WMS 발전소 데이터 수집
"""
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
data_url = system.get('data_url', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
# 1. 로그인
login_data = {
'act': 'loginChk',
'user_id': payload_id,
'user_pass': payload_pw
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
return []
except Exception as e:
print(f"{plant_name} 접속 에러: {e}")
return []
# 2. 데이터 요청
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
res.encoding = 'euc-kr'
content = res.text
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
today_kwh = float(match_today.group(1)) if match_today else 0.0
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': curr_kw,
'today': today_kwh,
'status': status
}]
except Exception as e:
print(f"{plant_name} 에러: {e}")
return []
def fetch_history_hourly(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 시간대별 과거 데이터 수집
실제 엔드포인트: /public/statics/statics.php
파라미터: tab01=0&tab02=1&tab03=2&tord=1&s_day=YYYY-MM-DD
"""
from datetime import datetime, timedelta
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
# base_url 추출
base_url = system.get('base_url', '')
if not base_url and 'http' in login_url:
base_url = login_url.split('/public')[0]
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 날짜 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
# 실제 확인된 시간별 엔드포인트
params = {
'tab01': '0',
'tab02': '1',
'tab03': '2',
'tord': '1',
's_day': date_str
}
try:
res = session.get(statics_url, params=params, headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
# 시간별 데이터 파싱
hourly_data = data.get('data', []) or data.get('list', [])
if isinstance(hourly_data, list) and len(hourly_data) > 0:
print(f" ✓ Found {len(hourly_data)} hourly records")
for item in hourly_data:
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
generation_kwh = safe_float(item.get('generation', item.get('kwh', 0)))
current_kw = safe_float(item.get('power', item.get('kw', 0)))
timestamp = f"{date_str} {hour}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': current_kw
})
else:
print(f" ⚠ No data for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results
def fetch_history_daily(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 일별 과거 데이터 수집
실제 엔드포인트: /public/statics/statics.php
파라미터: tab01=0&tab02=2&tab03=2&tord=2&s_day=YYYY-MM-DD&e_day=YYYY-MM-DD
"""
from datetime import datetime
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
# base_url 추출
base_url = system.get('base_url', '')
if not base_url and 'http' in login_url:
base_url = login_url.split('/public')[0]
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 실제 확인된 일별 엔드포인트
params = {
'tab01': '0',
'tab02': '2',
'tab03': '2',
'tord': '2',
's_day': start_date,
'e_day': end_date
}
try:
res = session.get(statics_url, params=params, headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
# 일별 데이터 파싱
daily_data = data.get('data', []) or data.get('list', [])
if isinstance(daily_data, list) and len(daily_data) > 0:
for item in daily_data:
date_str = item.get('date', item.get('day', ''))
generation_kwh = safe_float(item.get('generation', item.get('kwh', 0)))
current_kw = safe_float(item.get('power', item.get('kw', 0)))
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': generation_kwh,
'current_kw': current_kw
})
print(f"{date_str}: {generation_kwh:.2f}kWh")
except Exception as e:
print(f" ✗ Error: {e}")
print(f"[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
Sun-WMS 발전소의 월별 과거 데이터 수집
실제 엔드포인트: /public/statics/statics.php
파라미터: tab01=0&tab02=3&tab03=2&tord=3&s_day=YYYY-MM&e_day=YYYY-MM
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
# base_url 추출
base_url = system.get('base_url', '')
if not base_url and 'http' in login_url:
base_url = login_url.split('/public')[0]
statics_url = system.get('statics_url', f"{base_url}/public/statics/statics.php")
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Monthly] {plant_name} ({start_month} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 실제 확인된 월별 엔드포인트
params = {
'tab01': '0',
'tab02': '3',
'tab03': '2',
'tord': '3',
's_day': start_month,
'e_day': end_month
}
try:
res = session.get(statics_url, params=params, headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
# 월별 데이터 파싱
monthly_data = data.get('data', []) or data.get('list', [])
if isinstance(monthly_data, list) and len(monthly_data) > 0:
for item in monthly_data:
month_str = item.get('month', item.get('date', ''))
generation_kwh = safe_float(item.get('generation', item.get('kwh', item.get('monthTotal', 0))))
results.append({
'plant_id': plant_id,
'month': month_str[:7] if len(month_str) >= 7 else month_str,
'generation_kwh': generation_kwh
})
print(f"{month_str[:7]}: {generation_kwh:.1f}kWh")
except Exception as e:
print(f" ✗ Error: {e}")
print(f"[Total] Collected {len(results)} monthly records\n")
return results

View File

@ -1,343 +0,0 @@
# ==========================================
# crawlers/sun_wms.py - Sun-WMS 크롤러 (6호기)
# ==========================================
import requests
import re
import time
from .base import create_session
def fetch_data(plant_info):
"""
Sun-WMS 발전소 데이터 수집
"""
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
company_name = plant_info.get('company_name', '태양과바람')
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
data_url = system.get('data_url', '')
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
# 1. 로그인
login_data = {
'act': 'loginChk',
'user_id': payload_id,
'user_pass': payload_pw
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
return []
except Exception as e:
print(f"{plant_name} 접속 에러: {e}")
return []
# 2. 데이터 요청
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{data_url}?time={timestamp}", headers=headers)
res.encoding = 'euc-kr'
content = res.text
match_kw = re.search(r"id=['\"]cur_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
curr_kw = float(match_kw.group(1)) if match_kw else 0.0
match_today = re.search(r"id=['\"]today_power['\"].*?value=['\"]([^'\"]+)['\"]", content)
today_kwh = float(match_today.group(1)) if match_today else 0.0
status = "🟢 정상" if curr_kw > 0 else "💤 대기"
return [{
'id': plant_id,
'name': f'{company_name} {plant_name}',
'kw': curr_kw,
'today': today_kwh,
'status': status
}]
except Exception as e:
print(f"{plant_name} 에러: {e}")
return []
def fetch_history_daily(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 일별 과거 데이터 수집
"""
from datetime import datetime, timedelta
from .base import safe_float
import time
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Daily] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
# 일별 데이터 엔드포인트 (추정)
daily_url = f"{base_url}/public/chart/getDailyData.php?date={date_str}"
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{daily_url}&time={timestamp}", headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
daily_kwh = safe_float(data.get('daily', data.get('today', 0)))
results.append({
'plant_id': plant_id,
'date': date_str,
'generation_kwh': daily_kwh
})
print(f"{date_str}: {daily_kwh}kWh")
except Exception as e:
print(f"{date_str}: {e}")
current_date += timedelta(days=1)
print(f"[Total] Collected {len(results)} daily records\n")
return results
def fetch_history_monthly(plant_info, start_month, end_month):
"""
Sun-WMS 발전소의 월별 과거 데이터 수집
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta
from .base import safe_float
import time
results = []
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS Monthly] {plant_name} ({start_month} ~ {end_month})")
print(f"{'='*60}")
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {'act': 'loginChk', 'user_id': payload_id, 'user_pass': payload_pw}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code == 200:
print(" ✓ Login successful")
else:
print(" ✗ Login failed")
return results
except Exception as e:
print(f" ✗ Login error: {e}")
return results
current_month = datetime.strptime(start_month, '%Y-%m')
end_month_dt = datetime.strptime(end_month, '%Y-%m')
while current_month <= end_month_dt:
month_str = current_month.strftime('%Y-%m')
# 월별 데이터 엔드포인트 (추정)
monthly_url = f"{base_url}/public/chart/getMonthlyData.php?month={month_str}"
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{monthly_url}&time={timestamp}", headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
monthly_kwh = safe_float(data.get('monthly', data.get('month', 0)))
results.append({
'plant_id': plant_id,
'month': month_str,
'generation_kwh': monthly_kwh
})
print(f"{month_str}: {monthly_kwh}kWh")
except Exception as e:
print(f"{month_str}: {e}")
current_month += relativedelta(months=1)
print(f"[Total] Collected {len(results)} monthly records\n")
return results
def fetch_history_hourly(plant_info, start_date, end_date):
"""
Sun-WMS 발전소의 시간대별 과거 데이터 수집
Args:
plant_info: dict, 발전소 정보
start_date: str, 시작일 (YYYY-MM-DD)
end_date: str, 종료일 (YYYY-MM-DD)
Returns:
list: 시간대별 데이터 레코드
"""
from datetime import datetime, timedelta
from .base import safe_float
import time
results = []
# 설정 추출
plant_id = plant_info.get('id', 'sunwms-06')
auth = plant_info.get('auth', {})
system = plant_info.get('system', {})
plant_name = plant_info.get('name', '6호기')
payload_id = auth.get('payload_id', '')
payload_pw = auth.get('payload_pw', '')
login_url = system.get('login_url', '')
base_url = system.get('base_url', '')
session = create_session()
print(f"\n{'='*60}")
print(f"[Sun-WMS History] {plant_name} ({start_date} ~ {end_date})")
print(f"{'='*60}")
# 로그인
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'http://tb6.sun-wms.com/public/main/login.php',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}
login_data = {
'act': 'loginChk',
'user_id': payload_id,
'user_pass': payload_pw
}
try:
res = session.post(login_url, data=login_data, headers=headers)
if res.status_code != 200:
print(f" ✗ Login failed")
return results
print(f" ✓ Login successful")
except Exception as e:
print(f" ✗ Login error: {e}")
return results
# 날짜 범위 반복
current_date = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
while current_date <= end_dt:
date_str = current_date.strftime('%Y-%m-%d')
print(f"\n[Processing Date] {date_str}")
# 시간대별 데이터 엔드포인트 (추정)
hourly_url = f"{base_url}/public/chart/getHourlyData.php?date={date_str}"
try:
timestamp = int(time.time() * 1000)
res = session.get(f"{hourly_url}&time={timestamp}", headers=headers, timeout=10)
res.encoding = 'euc-kr'
if res.status_code == 200:
data = res.json()
hourly_data = data if isinstance(data, list) else data.get('hourly', [])
if hourly_data and len(hourly_data) > 0:
print(f" ✓ Found {len(hourly_data)} hourly records")
for item in hourly_data:
hour = str(item.get('hour', item.get('time', '00'))).zfill(2)
generation_kwh = safe_float(item.get('power', item.get('kwh', 0)))
current_kw = safe_float(item.get('kw', 0))
timestamp = f"{date_str} {hour}:00:00"
results.append({
'plant_id': plant_id,
'timestamp': timestamp,
'generation_kwh': generation_kwh,
'current_kw': current_kw
})
else:
print(f" ⚠ No hourly data for {date_str}")
else:
print(f" ✗ HTTP {res.status_code}")
except Exception as e:
print(f" ✗ Error: {e}")
# 다음 날짜로
current_date += timedelta(days=1)
print(f"\n{'='*60}")
print(f"[Total] Collected {len(results)} hourly records")
print(f"{'='*60}\n")
return results

View File

@ -1,204 +0,0 @@
# ==========================================
# daily_summary.py - 일일 발전 통계 집계
# ==========================================
# solar_logs 데이터를 집계하여 daily_stats 테이블에 저장
from datetime import datetime, timedelta, timezone
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
import pandas as pd
from database import get_supabase_client
def get_plant_capacities(client) -> dict:
"""plants 테이블에서 용량 정보 조회"""
try:
result = client.table("plants").select("id, capacity").execute()
return {row['id']: row.get('capacity', 99.0) for row in result.data}
except Exception as e:
print(f" ⚠️ 용량 조회 실패: {e}")
return {}
def calculate_daily_stats(date_str: str = None):
"""
특정 날짜의 발전 통계 집계
Args:
date_str: 집계 대상 날짜 (YYYY-MM-DD). 미지정 오늘.
"""
if date_str is None:
kst = timezone(timedelta(hours=9))
date_str = datetime.now(kst).strftime('%Y-%m-%d')
print(f"\n📊 [일일 통계 집계] {date_str}")
print("-" * 60)
client = get_supabase_client()
if not client:
print("❌ Supabase 연결 실패")
return False
# 1. 용량 정보 조회
capacities = get_plant_capacities(client)
# 2. 해당일 로그 조회 (KST 날짜 범위를 UTC로 변환하여 쿼리)
kst = timezone(timedelta(hours=9))
start_kst = datetime.strptime(f"{date_str} 00:00:00", "%Y-%m-%d %H:%M:%S").replace(tzinfo=kst)
end_kst = datetime.strptime(f"{date_str} 23:59:59", "%Y-%m-%d %H:%M:%S").replace(tzinfo=kst)
start_utc = start_kst.astimezone(timezone.utc).isoformat()
end_utc = end_kst.astimezone(timezone.utc).isoformat()
try:
result = client.table("solar_logs") \
.select("plant_id, current_kw, today_kwh, created_at") \
.gte("created_at", start_utc) \
.lte("created_at", end_utc) \
.order("created_at", desc=False) \
.execute()
if not result.data:
print(" ⚠️ 해당 날짜의 로그가 없습니다.")
return False
df = pd.DataFrame(result.data)
except Exception as e:
print(f" ❌ 로그 조회 실패: {e}")
return False
# 3. 발전소별 통계 계산
stats_list = []
for plant_id, group in df.groupby('plant_id'):
# 당일 최댓값 today_kwh 사용 (마지막 로그가 아닌 최댓값으로 중간 리셋 보호)
total_generation = group['today_kwh'].max() if len(group) > 0 else 0
# 최대 출력
peak_kw = group['current_kw'].max() if len(group) > 0 else 0
# 이용률 시간 = 발전량 / 용량
capacity = capacities.get(plant_id, 99.0)
generation_hours = round(total_generation / capacity, 2) if capacity > 0 else 0
stats = {
'plant_id': plant_id,
'date': date_str,
'total_generation': round(total_generation, 2),
'peak_kw': round(peak_kw, 2),
'generation_hours': generation_hours
}
stats_list.append(stats)
# 출력
print(f" {plant_id}: {total_generation:.1f}kWh ({generation_hours:.1f}시간, 최대 {peak_kw:.1f}kW)")
# 4. daily_stats 테이블에 Upsert
if stats_list:
try:
result = client.table("daily_stats").upsert(
stats_list,
on_conflict="plant_id,date"
).execute()
print("-" * 60)
print(f"{len(stats_list)}개 발전소 통계 저장 완료")
except Exception as e:
print(f" ❌ 저장 실패: {e}")
return False
return True
def calculate_monthly_stats(target_month: str):
"""
특정 월의 발전 통계 집계 (일간 데이터 합산)
Args:
target_month: YYYY-MM
"""
print(f"\n📅 [월간 통계 집계] {target_month}")
print("-" * 60)
client = get_supabase_client()
if not client:
return False
try:
# 1. 모든 발전소 ID 조회
plants_res = client.table("plants").select("id").execute()
plant_ids = [p['id'] for p in plants_res.data]
updated_count = 0
for pid in plant_ids:
# 2. 해당 월의 Daily 합계 조회
d_res = client.table("daily_stats").select("total_generation") \
.eq("plant_id", pid) \
.gte("date", f"{target_month}-01") \
.lte("date", f"{target_month}-31") \
.execute()
if not d_res.data:
continue
total_gen = sum(r.get('total_generation', 0) or 0 for r in d_res.data)
# 3. Monthly Upsert
client.table("monthly_stats").upsert({
"plant_id": pid,
"month": target_month,
"total_generation": round(total_gen, 2),
"updated_at": datetime.now().isoformat()
}, on_conflict="plant_id, month").execute()
print(f" {pid}: {total_gen:.1f}kWh (Month Total)")
updated_count += 1
print("-" * 60)
print(f"{updated_count}개 발전소 월간 통계 갱신 완료")
return True
except Exception as e:
print(f" ❌ 월간 집계 실패: {e}")
return False
if __name__ == "__main__":
import sys
from datetime import timedelta
# 인자로 날짜 지정 가능: python daily_summary.py 2026-01-22
if len(sys.argv) > 1:
target_date = sys.argv[1]
else:
# 인자 없으면 '어제' 날짜를 기본값으로 사용
# (새벽에 실행하여 전날 데이터를 마감하는 시나리오)
yesterday = datetime.now() - timedelta(days=1)
target_date = yesterday.strftime('%Y-%m-%d')
print(f" 날짜 미지정 -> 어제({target_date}) 기준으로 집계합니다.")
# 1. 일간 통계 집계
success = calculate_daily_stats(target_date)
# 2. 월말 체크 및 월간 집계 트리거
# target_date가 해당 월의 마지막 날이면 월간 집계 실행
if success:
try:
current_dt = datetime.strptime(target_date, '%Y-%m-%d')
import calendar
last_day = calendar.monthrange(current_dt.year, current_dt.month)[1]
if current_dt.day == last_day:
target_month = current_dt.strftime('%Y-%m')
print(f"\n🔔 월말({target_date}) 감지 -> {target_month} 월간 집계 실행")
calculate_monthly_stats(target_month)
except Exception as e:
print(f"⚠️ 월간 집계 트리거 오류: {e}")

View File

@ -1,345 +0,0 @@
# ==========================================
# database.py - Supabase 연동
# ==========================================
import os
from datetime import datetime
# 환경 변수에서 Supabase 설정 로드
SUPABASE_URL = os.getenv('SUPABASE_URL', '')
SUPABASE_KEY = os.getenv('SUPABASE_KEY', '')
print(f"DEBUG: SUPABASE_URL prefix: {SUPABASE_URL[:15] if SUPABASE_URL else 'None'}")
_supabase_client = None
def get_supabase_client():
"""Supabase 클라이언트 싱글턴 반환"""
global _supabase_client
if _supabase_client is None:
if not SUPABASE_URL or not SUPABASE_KEY:
print("⚠️ SUPABASE_URL 또는 SUPABASE_KEY가 설정되지 않았습니다.")
print(" .env 파일을 확인하거나 환경 변수를 설정하세요.")
return None
try:
from supabase import create_client
_supabase_client = create_client(SUPABASE_URL, SUPABASE_KEY)
print("✅ Supabase 연결 성공")
except ImportError:
print("⚠️ supabase 패키지가 설치되지 않았습니다.")
print(" pip install supabase 실행하세요.")
return None
except Exception as e:
print(f"⚠️ Supabase 연결 실패: {e}")
return None
return _supabase_client
def save_to_supabase(data_list):
"""
수집된 발전 데이터를 Supabase solar_logs 테이블에 저장
Args:
data_list: [{'id': 'nrems-01', 'name': '...', 'kw': 10.5, 'today': 100.0, 'status': '...'}]
Returns:
bool: 저장 성공 여부
"""
if not data_list:
print("[DB] 저장할 데이터가 없습니다.")
return False
client = get_supabase_client()
if client is None:
print("[DB 저장 생략] Supabase 연결 없음")
return False
try:
# 저장할 레코드 생성
records = []
for item in data_list:
plant_id = item.get('id', '')
# id가 없는 경우 건너뛰기
if not plant_id:
print(f" ⚠️ '{item.get('name', 'Unknown')}' ID 없음, 건너뜀")
continue
# 한국 시간(KST) 타임스탬프 생성
from datetime import timezone, timedelta
kst = timezone(timedelta(hours=9))
kst_now = datetime.now(kst).isoformat()
status = item.get('status', '')
is_error = '오류' in status # '🔴 오류' 상태 감지
# [보호] 오류 상태 데이터는 solar_logs에는 기록하되 daily_stats는 건드리지 않음
# 단, solar_logs 기록 자체는 이상 이력 추적을 위해 유지
record = {
'plant_id': plant_id,
'current_kw': float(item.get('kw', 0)),
'today_kwh': float(item.get('today', 0)),
'status': status,
'created_at': kst_now # 한국 시간으로 저장
}
records.append(record)
if not records:
print("[DB] 저장할 유효한 레코드가 없습니다.")
return False
# Supabase에 일괄 삽입 (solar_logs) - 오류 상태 포함 전체 기록
result = client.table("solar_logs").insert(records).execute()
print(f"✅ [DB] Supabase 저장 완료: {len(records)}건 (solar_logs)")
# daily_stats 테이블 업데이트 (Upsert)
# [보호 로직]
# 1. 오류 상태(크롤링 실패)인 경우 daily_stats 갱신 금지
# 2. today_kwh == 0인 경우 daily_stats 갱신 금지 (새벽 0 값으로 하루치 덮어쓰기 방지)
# 3. 야간 시간대(21:00~06:00 KST) daily_stats 갱신 금지 (일몰 이후 잔류값 보호)
# 4. DB에 이미 저장된 값보다 작은 경우 갱신 금지 (최댓값 보호)
kst = timezone(timedelta(hours=9))
kst_now_dt = datetime.now(kst)
kst_date_str = kst_now_dt.strftime("%Y-%m-%d")
kst_hour = kst_now_dt.hour
# 야간 시간대 차단 (21:00 ~ 익일 06:00 KST)
is_night = kst_hour >= 21 or kst_hour < 6
if is_night:
print(f" ⚠️ [야간 차단] KST {kst_hour:02d}시 → daily_stats 갱신 건너뜀 (일몰 후 잔류값 보호)")
else:
daily_records = []
# 기존 daily_stats 값 조회 (MAX 보호용)
try:
existing_res = client.table("daily_stats") \
.select("plant_id, total_generation") \
.eq("date", kst_date_str) \
.execute()
existing_map = {row['plant_id']: float(row.get('total_generation') or 0)
for row in existing_res.data}
except Exception as e:
print(f" ⚠️ [DB] 기존 daily_stats 조회 실패: {e}")
existing_map = {}
for item in data_list:
plant_id = item.get('id', '')
if not plant_id:
continue
status = item.get('status', '')
is_error = '오류' in status
today_val = float(item.get('today', 0))
# 오류 상태이거나 today_kwh가 0이면 daily_stats 갱신 건너뜀
if is_error:
print(f" ⚠️ [{plant_id}] 오류 상태 → daily_stats 갱신 건너뜀")
continue
if today_val == 0:
print(f" ⚠️ [{plant_id}] today_kwh=0 → daily_stats 갱신 건너뜀 (새벽/야간 추정)")
continue
# [MAX 보호] 기존 값보다 작으면 갱신 건너뜀
existing_val = existing_map.get(plant_id, 0)
if today_val <= existing_val:
print(f" ⚠️ [{plant_id}] 신규({today_val:.1f}) ≤ 기존({existing_val:.1f}) → daily_stats 갱신 건너뜀 (최댓값 보호)")
continue
daily_records.append({
"plant_id": plant_id,
"date": kst_date_str,
"total_generation": today_val,
"created_at": kst_now
# updated_at은 자동으로 NOW()로 설정됨 (DB 기본값)
})
if daily_records:
try:
stats_result = client.table("daily_stats").upsert(daily_records, on_conflict="plant_id, date").execute()
print(f"✅ [DB] daily_stats 업데이트 완료: {len(daily_records)}")
except Exception as e:
print(f"⚠️ [DB] daily_stats 업데이트 실패: {e}")
for r in records:
print(f"{r['plant_id']}: {r['current_kw']} kW / {r['today_kwh']} kWh")
return True
except Exception as e:
print(f"❌ [DB] Supabase 저장 실패: {e}")
return False
def save_to_console(data_list):
"""콘솔에 데이터 출력"""
if not data_list:
print("⚠️ 출력할 데이터가 없습니다.")
return
print("\n" + "=" * 75)
print("📊 [실시간 통합 현황판]")
print("=" * 75)
print(f"{'발전소명':<20} | {'현재출력(kW)':>12} | {'금일발전(kWh)':>12} | {'상태'}")
print("-" * 75)
total_kw = 0
total_today = 0
for d in data_list:
name = d.get('name', 'N/A')
kw = d.get('kw', 0)
today = d.get('today', 0)
status = d.get('status', '')
total_kw += kw
total_today += today
print(f"{name:<20} | {kw:>12.2f} | {today:>12.2f} | {status}")
print("-" * 75)
print(f"{'합계':<20} | {total_kw:>12.2f} | {total_today:>12.2f} |")
print("=" * 75)
def save_history(data_list, data_type='hourly'):
"""
과거 데이터 저장 (Hourly, Daily, Monthly)
Args:
data_list: 데이터 리스트
data_type: 'hourly', 'daily', 'monthly'
"""
if not data_list:
return False
client = get_supabase_client()
if client is None:
return False
try:
table_name = ""
records = []
if data_type == 'hourly':
table_name = "solar_logs"
for item in data_list:
# hourly 데이터는 timestamp 키를 가짐
ts = item.get('timestamp')
if ts:
ts_iso = ts.replace(' ', 'T')
# Check if future (simple string comparison works for ISO format if consistent, but datetime is safer)
# KST aware comparison
from datetime import timezone, timedelta
kst = timezone(timedelta(hours=9))
now_kst = datetime.now(kst)
try:
# ts example: 2026-01-27 14:00:00. Assume input is local time (KST)
# We convert it to aware datetime
dt_ts = datetime.fromisoformat(ts_iso)
if dt_ts.tzinfo is None:
dt_ts = dt_ts.replace(tzinfo=kst)
if dt_ts > now_kst:
continue # Skip future data
except ValueError:
pass # robust date parsing needed if format varies
# Ensure timezone is sent to Supabase to prevent UTC assumption
final_created_at = dt_ts.isoformat()
if item.get('current_kw') is not None:
current_kw = float(item['current_kw'])
else:
current_kw = float(item.get('generation_kwh', 0))
records.append({
'plant_id': item['plant_id'],
'created_at': final_created_at,
'current_kw': current_kw,
'today_kwh': float(item.get('generation_kwh', 0)),
'status': 'History'
})
elif data_type == 'daily':
table_name = "daily_stats"
for item in data_list:
records.append({
'plant_id': item['plant_id'],
'date': item['date'],
'total_generation': float(item.get('generation_kwh', 0))
# 'updated_at': datetime.now().isoformat()
})
elif data_type == 'monthly':
table_name = "monthly_stats"
for item in data_list:
records.append({
'plant_id': item['plant_id'],
'month': item['month'], # YYYY-MM
'total_generation': float(item.get('generation_kwh', 0)),
'updated_at': datetime.now().isoformat()
})
if not records:
return False
# upsert 사용
if data_type == 'hourly':
client.table(table_name).insert(records).execute()
elif data_type == 'daily':
client.table(table_name).upsert(records, on_conflict="plant_id, date").execute()
# [Auto Update] Daily 데이터 저장 시 Monthly 통계 자동 갱신
# 1. 업데이트된 월 목록 추출
updated_months = set()
for rec in records:
try:
# date: YYYY-MM-DD
month_key = rec['date'][:7]
updated_months.add((rec['plant_id'], month_key))
except:
pass
if updated_months:
monthly_upserts = []
for (pid, m_key) in updated_months:
# 2. 해당 월의 Daily 합계 조회 (DB Aggregation)
import calendar
try:
year, month_int = map(int, m_key.split('-'))
_, last_day = calendar.monthrange(year, month_int)
except:
last_day = 31
d_res = client.table("daily_stats").select("total_generation") \
.eq("plant_id", pid) \
.gte("date", f"{m_key}-01") \
.lte("date", f"{m_key}-{last_day}") \
.execute()
total_gen = sum(r['total_generation'] or 0 for r in d_res.data)
monthly_upserts.append({
"plant_id": pid,
"month": m_key,
"total_generation": round(total_gen, 2),
"updated_at": datetime.now().isoformat()
})
# 3. Monthly Upsert
if monthly_upserts:
client.table("monthly_stats").upsert(monthly_upserts, on_conflict="plant_id, month").execute()
print(f" 🔄 [Sync] {len(monthly_upserts)}개월치 Monthly Stats 자동 갱신 완료")
elif data_type == 'monthly':
client.table(table_name).upsert(records, on_conflict="plant_id, month").execute()
print(f"✅ [History] {data_type} 데이터 {len(records)}건 저장 완료")
return True
except Exception as e:
print(f"❌ [History] 저장 실패 ({data_type}): {e}")
return False

View File

@ -1,138 +0,0 @@
import sys
import os
import importlib
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from dotenv import load_dotenv
# .env 로드
load_dotenv()
# Windows 인코딩 문제 해결
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# 프로젝트 루트 경로 추가
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from config import get_all_plants
from database import save_history
def get_plant_config(target_id):
plants = get_all_plants()
for p in plants:
# 일반 매칭
if p.get('id') == target_id:
return p
# NREMS 분리 세대 매칭 (nrems-01, nrems-02)
if p.get('options', {}).get('is_split'):
if target_id == 'nrems-01':
p['id'] = 'nrems-01'
p['options']['split_index'] = 1
return p
elif target_id == 'nrems-02':
p['id'] = 'nrems-02'
p['options']['split_index'] = 2
return p
return None
def fetch_and_save(plant_config):
plant_id = plant_config['id']
plant_type = plant_config['type']
plant_name = plant_config['name']
start_date_str = plant_config.get('start_date', '2020-01-01')
print(f"🚀 [{plant_name}] 과거 데이터 수집 시작 ({plant_id})")
print(f" 타입: {plant_type}, 가동개시일: {start_date_str}")
# 크롤러 모듈 동적 임포트
try:
crawler_module = importlib.import_module(f"crawlers.{plant_type}")
except ImportError:
print(f"❌ 크롤러 모듈을 찾을 수 없습니다: crawlers/{plant_type}.py")
return
now = datetime.now()
today_str = now.strftime("%Y-%m-%d")
current_year = now.year
current_month = now.month
# 1. 시간별 데이터 (Hourly): 이번 달 1일 ~ 오늘
# (역순으로 가져오라고 했지만, 크롤러는 start->end로 동작하므로 범위로 호출)
try:
h_start = now.replace(day=1).strftime("%Y-%m-%d")
h_end = today_str
print(f"\n⏳ [Hourly] 수집 : {h_start} ~ {h_end}")
if hasattr(crawler_module, 'fetch_history_hourly'):
hourly_data = crawler_module.fetch_history_hourly(plant_config, h_start, h_end)
if hourly_data:
save_history(hourly_data, 'hourly')
else:
print(" 데이터 없음")
else:
print(f" {plant_type}는 시간별 이력 수집을 지원하지 않음")
except Exception as e:
print(f"❌ [Hourly] 에러: {e}")
# 2. 일별 데이터 (Daily): 발전소 가동일 ~ 오늘
# API 서버가 daily_stats를 집계하여 월/년 통계를 보여주므로, daily 데이터를 전체 기간 수집해야 함.
try:
# d_start = f"{current_year}-01-01"
d_start = start_date_str # 가동 시작일부터 수집
d_end = today_str
print(f"\n⏳ [Daily] 수집 : {d_start} ~ {d_end}")
if hasattr(crawler_module, 'fetch_history_daily'):
daily_data = crawler_module.fetch_history_daily(plant_config, d_start, d_end)
if daily_data:
save_history(daily_data, 'daily')
else:
print(" 데이터 없음")
else:
print(f" {plant_type}는 일별 이력 수집을 지원하지 않음")
except Exception as e:
print(f"❌ [Daily] 에러: {e}")
# 3. 월별 데이터 (Monthly): 사용 안함 (API가 daily_stats 집계 사용)
# try:
# m_start_dt = datetime.strptime(start_date_str, "%Y-%m-%d")
# m_start = m_start_dt.strftime("%Y-%m")
# m_end = now.strftime("%Y-%m")
# print(f"\n⏳ [Monthly] 수집 : {m_start} ~ {m_end}")
#
# if hasattr(crawler_module, 'fetch_history_monthly'):
# monthly_data = crawler_module.fetch_history_monthly(plant_config, m_start, m_end)
# if monthly_data:
# save_history(monthly_data, 'monthly')
# else:
# print(" 데이터 없음")
# else:
# print(f" {plant_type}는 월별 이력 수집을 지원하지 않음")
#
# except Exception as e:
# print(f"❌ [Monthly] 에러: {e}")
except Exception as e:
print(f"❌ [Monthly] 에러: {e}")
print(f"\n✅ [{plant_name}] 모든 작업 완료")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python fetch_history.py <plant_id>")
sys.exit(1)
target_plant_id = sys.argv[1]
cfg = get_plant_config(target_plant_id)
if cfg:
fetch_and_save(cfg)
else:
print(f"❌ 설정을 찾을 수 없습니다: {target_plant_id}")

View File

@ -1,203 +0,0 @@
# ==========================================
# main.py - 태양광 발전 통합 관제 시스템
# ==========================================
import re
from datetime import datetime, timezone, timedelta
# 환경 변수 로드 (최상단에서 실행)
try:
from dotenv import load_dotenv
load_dotenv()
print("✅ 환경 변수 로드 완료")
except ImportError:
print("⚠️ python-dotenv가 설치되지 않았습니다. 환경 변수를 직접 설정하세요.")
from config import get_all_plants
from database import save_to_supabase, save_to_console
from crawlers import get_crawler
from crawler_manager import CrawlerManager
from alert_manager import AlertManager
# 스마트 스케줄러 초기화
crawler_manager = CrawlerManager()
def extract_unit_number(name):
"""발전소 이름에서 호기 번호 추출 (정렬용)"""
match = re.search(r'(\d+)호기', name)
if match:
return int(match.group(1))
return 999
def integrated_monitoring(save_to_db=True, company_filter=None, force_run=False):
"""
통합 모니터링 실행
Args:
save_to_db: True면 Supabase에 저장
company_filter: 특정 업체만 필터링 (: 'sunwind')
force_run: True면 스케줄러 무시하고 강제 실행
"""
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"\n🚀 [통합 관제 시스템] 데이터 수집 시작... ({now_str})")
print("-" * 75)
# 평탄화된 발전소 목록 가져오기
all_plants = get_all_plants()
# 업체 필터링 (옵션)
if company_filter:
all_plants = [p for p in all_plants if p['company_id'] == company_filter]
print(f"📌 필터 적용: {company_filter}")
total_results = []
skipped_count = 0
# 알림 매니저 초기화
alert_manager = AlertManager()
for plant in all_plants:
plant_type = plant['type']
plant_name = plant.get('display_name', plant.get('name', 'Unknown'))
company_id = plant.get('company_id', '')
company_name = plant.get('company_name', '')
# 크롤링 결과에서 생성되는 site_id 목록 (1,2호기 분리 처리 고려)
is_split = plant.get('options', {}).get('is_split', False)
if is_split:
site_ids = ['nrems-01', 'nrems-02']
else:
site_ids = [plant.get('id', '')]
# 야간 시간대 체크 (force_run이 아닌 경우)
if not force_run:
# 대표 site_id 하나로 야간 여부 확인 (모든 사이트 동일 조건)
representative_id = site_ids[0] if site_ids else ''
if representative_id and not crawler_manager.should_run(representative_id):
print(f" ⏭️ [{plant_type.upper()}] {plant_name} 스킵 (야간 시간대)")
skipped_count += 1
continue
print(f"📡 [{plant_type.upper()}] {company_name} - {plant_name} 수집 중...")
try:
crawler_func = get_crawler(plant_type)
if crawler_func:
data = crawler_func(plant)
if data:
# company_id, company_name 주입 + 변경 여부 판단
for item in data:
item['company_id'] = company_id
item['company_name'] = company_name
item['_data_changed'] = False # 기본값: 저장 안 함
item_id = item.get('id', '')
# 알림은 항상 체크 (0kW 감지 목적)
alert_info = plant.copy()
alert_info['id'] = item_id
alert_info['name'] = item.get('name', plant_name)
alert_manager.check_and_alert(alert_info, item.get('kw', 0))
if item_id:
# 크롤링 성공 기록 (항상)
crawler_manager.record_run(item_id)
# 데이터 변경 여부 확인
# → 원격 서버가 실제로 업데이트했는지 감지
# → True면 DB 저장 대상 / False면 중복 저장 방지
if crawler_manager.check_data_change(item_id, item):
crawler_manager.analyze_and_optimize(item_id)
item['_data_changed'] = True
else:
print(f" ⏸️ [{item_id}] 데이터 변경 없음, DB 저장 스킵")
# 변경된 항목만 DB 저장 대상에 포함
for item in data:
if item.pop('_data_changed', False):
total_results.append(item)
else:
print(f" ⚠️ 알 수 없는 크롤러 타입: {plant_type}")
except Exception as e:
print(f"{plant_name} 실패: {e}")
# 정렬 (호기 번호 순)
total_results.sort(key=lambda x: extract_unit_number(x['name']))
# 중복 제거 (company_id + id 조합)
seen_keys = set()
unique_results = []
for item in total_results:
unique_key = f"{item.get('company_id', '')}_{item.get('id', '')}"
if unique_key not in seen_keys:
seen_keys.add(unique_key)
unique_results.append(item)
total_results = unique_results
print("-" * 75)
if skipped_count > 0:
print(f"📊 스킵된 사이트: {skipped_count}개 (야간 시간대)")
if total_results:
# 콘솔 출력
save_to_console(total_results)
# DB 저장
if save_to_db:
save_to_supabase(total_results)
# 이상 감지 로직
current_hour = datetime.now().hour
if 10 <= current_hour <= 17:
issues = [d['name'] for d in total_results if d.get('kw', 0) == 0]
if issues:
print("\n🚨 [이상 감지 리포트]")
for name in issues:
print(f" ⚠️ 경고: '{name}' 발전량이 0입니다! 확인 필요.")
else:
print("\n ✅ 현재 모든 발전소가 정상 가동 중입니다.")
else:
print("❌ 수집된 데이터가 없습니다.")
return total_results
def run_daily_close(force=False):
"""
일일 마감 집계 실행 (KST 21:00~21:10 자동 트리거 또는 force=True)
solar_logs 데이터를 집계하여 daily_stats에 당일 최종값을 확정합니다.
"""
kst = timezone(timedelta(hours=9))
kst_now = datetime.now(kst)
kst_hour = kst_now.hour
kst_minute = kst_now.minute
is_close_window = (kst_hour == 21 and kst_minute < 10)
if force or is_close_window:
date_str = kst_now.strftime('%Y-%m-%d')
print(f"\n🌙 [KST {kst_hour:02d}:{kst_minute:02d}] 일일 마감 집계 트리거 → {date_str} 통계 확정")
try:
from daily_summary import calculate_daily_stats
calculate_daily_stats(date_str)
except Exception as e:
print(f" ❌ 마감 집계 실패: {e}")
else:
print(f" 마감 집계 스킵 (KST {kst_hour:02d}{kst_minute:02d}분, 대상 시간대 아님)")
if __name__ == "__main__":
import sys
# 인자 처리: --force 옵션으로 스케줄러 무시
force_run = '--force' in sys.argv or '-f' in sys.argv
force_close = '--close' in sys.argv # 마감 집계 강제 실행
if force_run:
print("⚡ [강제 실행 모드] 스케줄러 무시하고 모든 사이트 크롤링")
integrated_monitoring(save_to_db=True, force_run=force_run)
# 마감 집계: 21:00~21:10 KST 자동 실행 또는 --close 옵션
run_daily_close(force=force_close)

View File

@ -1,139 +0,0 @@
# Scripts Archive - 2월 데이터 패치
## 📅 작업 일시
2026년 2월 27일
## 🎯 작업 목적
5호기(kremc-05)와 9호기(nrems-09)의 2월 데이터를 Supabase DB에 완전히 크롤링하고 저장
## ⚠️ 발견된 문제
1. **중복 저장**: 시간별 데이터가 5~6배 중복 저장됨
2. **current_kw = 0**: 과거 데이터의 current_kw가 0으로 저장되어 웹 차트가 안 나옴
## ✅ 완료된 작업
### 1. 데이터 크롤링 및 저장
- **대상**: 5호기, 9호기
- **기간**: 2026년 2월 1일 ~ 2월 27일
- **데이터 유형**: 시간별(hourly), 일별(daily), 월별(monthly)
### 2. 중복 데이터 정리
- 시간별 데이터가 중복 저장된 문제 발견 및 해결
- 각 시간대별로 최신 레코드만 유지하도록 정리
- **5호기**: 2,949건 중복 제거
- **9호기**: 2,839건 중복 제거
### 3. current_kw 업데이트 문제 해결
- **문제**: 과거 데이터의 current_kw가 0으로 저장되어 웹 차트가 표시되지 않음
- **원인**: 과거 데이터 크롤링 시 current_kw 필드가 0으로 저장됨
- **해결**: current_kw를 today_kwh(시간별 발전량) 값으로 업데이트
- **5호기**: 263건 업데이트
- **9호기**: 308건 업데이트
### 4. 최종 결과
#### 5호기 (kremc-05)
- ✅ 시간별 데이터: 646건
- ✅ 일별 데이터: 27건 (2/1~2/27)
- ✅ 2월 총 발전량: 3,702 kWh
- ✅ 일평균: 137.11 kWh
- ✅ 월별 통계: 자동 갱신 완료
#### 9호기 (nrems-09)
- ✅ 시간별 데이터: 646건
- ✅ 일별 데이터: 27건 (2/1~2/27)
- ✅ 2월 총 발전량: 9,230 kWh
- ✅ 일평균: 341.85 kWh
- ✅ 월별 통계: 자동 갱신 완료
## 📁 아카이브된 스크립트
### 1. `fetch_february.py`
- **목적**: 5호기와 9호기의 2월 전체 데이터 크롤링
- **기능**:
- 시간별 데이터 수집 (2/1~2/27)
- 일별 데이터 수집 (2/1~2/27)
- Supabase DB 저장
### 2. `verify_february_data.py`
- **목적**: Supabase DB에 저장된 2월 데이터 검증
- **기능**:
- 시간별/일별/월별 데이터 개수 확인
- 발전량 통계 집계
- 샘플 데이터 출력
### 3. `check_feb_gaps.py`
- **목적**: 2월 시간별 데이터의 날짜별 누락 확인
- **기능**:
- 2월 1일~27일 각 날짜의 시간별 데이터 개수 확인
- 완전 누락/부분 누락 날짜 보고
### 4. `clean_feb_duplicates.py`
- **목적**: 중복 저장된 시간별 데이터 정리
- **기능**:
- 같은 plant_id와 시간대의 중복 레코드 탐지
- 가장 최신 레코드만 유지, 나머지 삭제
- 날짜별 중복 제거 현황 출력
### 5. `fill_today_feb.py`
- **목적**: 2월 27일(오늘) 누락 시간대 보완
- **기능**:
- 현재 DB에 있는 시간대 확인
- 누락된 시간대만 추가 크롤링
- 일별 통계 업데이트
### 6. `check_current_kw.py`
- **목적**: DB에 저장된 시간별 데이터의 current_kw 값 확인
- **기능**:
- 특정 날짜의 시간별 데이터 조회
- current_kw와 today_kwh 값 비교
- current_kw=0인 레코드 개수 통계
### 7. `update_current_kw.py`
- **목적**: 2월 데이터의 current_kw를 today_kwh로 업데이트
- **기능**:
- current_kw가 0이고 today_kwh가 0이 아닌 레코드 탐지
- current_kw를 today_kwh 값으로 업데이트
- 날짜별 업데이트 현황 출력
- **배경**: 과거 데이터 크롤링 시 current_kw가 0으로 저장되어 웹 차트가 안 나오는 문제 해결
### 8. `test_api.py`
- **목적**: API 엔드포인트 호출 테스트
- **기능**:
- /plants/{plant_id}/stats/today 엔드포인트 테스트
- 시간별 데이터 응답 확인
- current_kw와 today_kwh 값 출력
### 9. `verify_feb_final.py`
- **목적**: 2월 데이터 최종 검증 (간단 버전)
- **기능**:
- 시간별/일별/월별 데이터 개수 확인
- 발전량 통계 요약
- DB 저장 상태 최종 확인
## 🔧 사용 방법
모든 스크립트는 crawler 가상환경에서 실행:
```powershell
cd d:\dev\etc\SolorPower\crawler
.\venv_win\Scripts\Activate.ps1
# 스크립트 실행 예시
python scripts_archive/fetch_february.py
python scripts_archive/verify_february_data.py
python scripts_archive/check_feb_gaps.py
python scripts_archive/clean_feb_duplicates.py
python scripts_archive/fill_today_feb.py
```
## 📝 참고사항
- 이 스크립트들은 일회성 패치 작업용입니다
- 정규 크롤링은 `main.py``crawler_manager.py`를 사용하세요
- 유사한 데이터 패치 작업이 필요할 경우 이 스크립트들을 참고하여 수정 가능
## ⚠️ 주의사항
- `clean_feb_duplicates.py`는 데이터를 삭제하므로 신중히 사용
- 중복 제거 전 반드시 DB 백업 권장
- 시간대 필터링 시 KST(UTC+9) 타임존 고려 필요

View File

@ -1,89 +0,0 @@
"""
DB에 저장된 시간별 데이터의 current_kw 확인
"""
import sys
import os
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from database import get_supabase_client
def check_current_kw(plant_id, plant_name, date_str):
"""특정 날짜의 시간별 데이터 current_kw 값 확인"""
print(f"\n{'='*70}")
print(f"🔍 [{plant_name}] {date_str} 시간별 데이터 확인")
print(f"{'='*70}")
client = get_supabase_client()
if not client:
return
# 해당 날짜의 시간별 데이터 조회
result = client.table("solar_logs") \
.select("created_at, current_kw, today_kwh") \
.eq("plant_id", plant_id) \
.gte("created_at", f"{date_str}T00:00:00+09:00") \
.lt("created_at", f"{date_str}T23:59:59+09:00") \
.order("created_at", desc=False) \
.limit(30) \
.execute()
if not result.data:
print(" ❌ 데이터 없음")
return
print(f"{len(result.data)}건 (최대 30건만 표시)\n")
print(f" {'시간':<20} | {'current_kw':>12} | {'today_kwh':>12}")
print(f" {'-'*20}+{'-'*14}+{'-'*14}")
current_kw_zero_count = 0
current_kw_nonzero_count = 0
for record in result.data:
created_at = record['created_at']
current_kw = record.get('current_kw', 0) or 0
today_kwh = record.get('today_kwh', 0) or 0
if current_kw == 0:
current_kw_zero_count += 1
else:
current_kw_nonzero_count += 1
print(f" {created_at:<20} | {current_kw:>12.2f} | {today_kwh:>12.2f}")
print(f"\n 📊 통계:")
print(f" current_kw = 0: {current_kw_zero_count}")
print(f" current_kw ≠ 0: {current_kw_nonzero_count}")
if current_kw_zero_count == len(result.data):
print(f"\n ⚠️ 모든 current_kw 값이 0입니다!")
print(f" ⚠️ 과거 데이터는 current_kw 대신 today_kwh(시간별 발전량)가 저장됩니다.")
print(f" ⚠️ 차트는 today_kwh를 사용해야 합니다.")
def main():
plants = [
('kremc-05', '5호기'),
('nrems-09', '9호기')
]
dates = ['2026-02-25', '2026-02-01']
for plant_id, plant_name in plants:
for date_str in dates:
check_current_kw(plant_id, plant_name, date_str)
print(f"\n{'='*70}\n")
if __name__ == "__main__":
main()

View File

@ -1,91 +0,0 @@
"""
2 데이터 누락 확인 스크립트
정확히 어느 날짜의 데이터가 누락되었는지 확인
"""
import sys
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv()
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from database import get_supabase_client
def check_gaps(plant_id, plant_name):
"""날짜별 시간별 데이터 누락 확인"""
print(f"\n{'='*70}")
print(f"🔍 [{plant_name}] 2월 시간별 데이터 누락 확인")
print(f"{'='*70}")
client = get_supabase_client()
if client is None:
return
# 2월 1일부터 27일까지 확인
start = datetime(2026, 2, 1)
end = datetime(2026, 2, 27)
current = start
missing_dates = []
partial_dates = []
while current <= end:
date_str = current.strftime("%Y-%m-%d")
# 해당 날짜의 시간별 데이터 개수 확인
result = client.table("solar_logs") \
.select("*", count='exact') \
.eq("plant_id", plant_id) \
.gte("created_at", f"{date_str}T00:00:00+09:00") \
.lt("created_at", f"{(current + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+09:00") \
.execute()
count = result.count if hasattr(result, 'count') else len(result.data)
if count == 0:
missing_dates.append(date_str)
print(f"{date_str}: 데이터 없음")
elif count < 24:
partial_dates.append((date_str, count))
print(f" ⚠️ {date_str}: {count}건 (불완전)")
else:
print(f"{date_str}: {count}")
current += timedelta(days=1)
print(f"\n📊 요약:")
print(f" 완전 누락: {len(missing_dates)}")
print(f" 부분 누락: {len(partial_dates)}")
if missing_dates:
print(f"\n 누락된 날짜:")
for d in missing_dates:
print(f" - {d}")
if partial_dates:
print(f"\n 부분 누락된 날짜:")
for d, c in partial_dates:
print(f" - {d}: {c}/24건")
def main():
plants = [
('kremc-05', '5호기'),
('nrems-09', '9호기')
]
for plant_id, plant_name in plants:
check_gaps(plant_id, plant_name)
print(f"\n{'='*70}\n")
if __name__ == "__main__":
main()

View File

@ -1,108 +0,0 @@
"""
2 시간별 데이터 중복 제거 스크립트
같은 plant_id와 시간대에 중복된 데이터를 정리
"""
import sys
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv()
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from database import get_supabase_client
def clean_duplicates(plant_id, plant_name):
"""중복 데이터 제거 - 같은 시간대에 가장 최신 레코드만 유지"""
print(f"\n{'='*70}")
print(f"🧹 [{plant_name}] 중복 데이터 정리 중...")
print(f"{'='*70}")
client = get_supabase_client()
if client is None:
return
# 2월 1일부터 27일까지
start = datetime(2026, 2, 1)
end = datetime(2026, 2, 27)
total_deleted = 0
current = start
while current <= end:
date_str = current.strftime("%Y-%m-%d")
# 해당 날짜의 모든 시간별 데이터 가져오기
result = client.table("solar_logs") \
.select("*") \
.eq("plant_id", plant_id) \
.gte("created_at", f"{date_str}T00:00:00+09:00") \
.lt("created_at", f"{(current + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+09:00") \
.order("created_at", desc=False) \
.execute()
if not result.data:
current += timedelta(days=1)
continue
# 시간대별로 그룹화 (created_at의 시간 부분으로)
hour_groups = {}
for record in result.data:
# created_at에서 날짜+시간만 추출 (분/초 제거)
ts = record['created_at']
hour_key = ts[:13] # 2026-02-01T00 형식
if hour_key not in hour_groups:
hour_groups[hour_key] = []
hour_groups[hour_key].append(record)
# 각 시간대별로 중복 제거 (가장 최근 id만 유지)
deleted_count = 0
for hour_key, records in hour_groups.items():
if len(records) > 1:
# id 기준으로 정렬 (가장 큰 id가 최신)
records.sort(key=lambda x: x['id'], reverse=True)
# 첫 번째(최신)를 제외한 나머지 삭제
for old_record in records[1:]:
try:
client.table("solar_logs").delete().eq("id", old_record['id']).execute()
deleted_count += 1
except Exception as e:
print(f" ⚠️ 삭제 실패 (id: {old_record['id']}): {e}")
if deleted_count > 0:
print(f" 🧹 {date_str}: {deleted_count}건 중복 제거 (남은 시간대: {len(hour_groups)}개)")
total_deleted += deleted_count
current += timedelta(days=1)
print(f"\n✅ [{plant_name}] 총 {total_deleted}건 중복 제거 완료")
def main():
plants = [
('kremc-05', '5호기'),
('nrems-09', '9호기')
]
print("\n" + "="*70)
print("🧹 2월 시간별 데이터 중복 제거 시작")
print("="*70)
for plant_id, plant_name in plants:
clean_duplicates(plant_id, plant_name)
print("\n" + "="*70)
print("🎉 중복 제거 완료!")
print("="*70 + "\n")
if __name__ == "__main__":
main()

View File

@ -1,135 +0,0 @@
"""
2 데이터 크롤링 스크립트
5호기(kremc-05), 9호기(nrems-09) 2 일별/시간별 데이터를 수집합니다.
"""
import sys
import os
import importlib
from datetime import datetime
from dotenv import load_dotenv
# .env 로드
load_dotenv()
# Windows 인코딩 문제 해결
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# 프로젝트 루트 경로 추가
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from config import get_all_plants
from database import save_history
def get_plant_config(target_id):
"""플랜트 설정 가져오기"""
plants = get_all_plants()
for p in plants:
if p.get('id') == target_id:
return p
return None
def fetch_february_data(plant_config):
"""2월 데이터 수집"""
plant_id = plant_config['id']
plant_type = plant_config['type']
plant_name = plant_config['name']
print(f"\n{'='*60}")
print(f"🚀 [{plant_name}] 2월 데이터 수집 시작 ({plant_id})")
print(f" 타입: {plant_type}")
print(f"{'='*60}")
# 크롤러 모듈 동적 임포트
try:
crawler_module = importlib.import_module(f"crawlers.{plant_type}")
except ImportError:
print(f"❌ 크롤러 모듈을 찾을 수 없습니다: crawlers/{plant_type}.py")
return
# 2월 데이터 범위 설정
now = datetime.now()
year = now.year
# 2월 1일부터 오늘까지 (또는 2월 말일까지)
start_date = f"{year}-02-01"
# 현재가 2월이면 오늘까지, 3월 이후면 2월 마지막 날까지
if now.month == 2:
end_date = now.strftime("%Y-%m-%d")
else:
# 2월 마지막 날 (윤년 고려)
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
end_date = f"{year}-02-29"
else:
end_date = f"{year}-02-28"
print(f"\n📅 수집 기간: {start_date} ~ {end_date}")
# 1. 시간별 데이터 수집
try:
print(f"\n⏳ [Hourly] 시간별 데이터 수집 중...")
if hasattr(crawler_module, 'fetch_history_hourly'):
hourly_data = crawler_module.fetch_history_hourly(plant_config, start_date, end_date)
if hourly_data:
print(f"{len(hourly_data)}개 시간별 데이터 수집 완료")
save_history(hourly_data, 'hourly')
print(f" ✅ DB 저장 완료")
else:
print(" ⚠️ 데이터 없음")
else:
print(f" ⚠️ {plant_type}는 시간별 이력 수집을 지원하지 않음")
except Exception as e:
print(f"❌ [Hourly] 에러: {e}")
import traceback
traceback.print_exc()
# 2. 일별 데이터 수집
try:
print(f"\n⏳ [Daily] 일별 데이터 수집 중...")
if hasattr(crawler_module, 'fetch_history_daily'):
daily_data = crawler_module.fetch_history_daily(plant_config, start_date, end_date)
if daily_data:
print(f"{len(daily_data)}개 일별 데이터 수집 완료")
save_history(daily_data, 'daily')
print(f" ✅ DB 저장 완료")
else:
print(" ⚠️ 데이터 없음")
else:
print(f" ⚠️ {plant_type}는 일별 이력 수집을 지원하지 않음")
except Exception as e:
print(f"❌ [Daily] 에러: {e}")
import traceback
traceback.print_exc()
print(f"\n✅ [{plant_name}] 모든 작업 완료\n")
def main():
"""메인 실행 함수"""
target_plants = ['kremc-05', 'nrems-09'] # 5호기, 9호기
print("\n" + "="*60)
print("🌞 2월 데이터 크롤링 시작")
print(f"대상: 5호기(kremc-05), 9호기(nrems-09)")
print("="*60)
for plant_id in target_plants:
cfg = get_plant_config(plant_id)
if cfg:
fetch_february_data(cfg)
else:
print(f"❌ 설정을 찾을 수 없습니다: {plant_id}")
print("\n" + "="*60)
print("🎉 모든 작업 완료!")
print("="*60 + "\n")
if __name__ == "__main__":
main()

View File

@ -1,127 +0,0 @@
"""
2 27 누락 시간대 보완 크롤링
"""
import sys
import os
import importlib
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from config import get_all_plants
from database import save_history, get_supabase_client
def get_plant_config(target_id):
plants = get_all_plants()
for p in plants:
if p.get('id') == target_id:
return p
return None
def fill_today(plant_config):
plant_id = plant_config['id']
plant_type = plant_config['type']
plant_name = plant_config['name']
print(f"\n{'='*60}")
print(f"🚀 [{plant_name}] 오늘 데이터 보완 ({plant_id})")
print(f"{'='*60}")
# 크롤러 모듈 동적 임포트
try:
crawler_module = importlib.import_module(f"crawlers.{plant_type}")
except ImportError:
print(f"❌ 크롤러 모듈을 찾을 수 없습니다: crawlers/{plant_type}.py")
return
today = datetime.now().strftime("%Y-%m-%d")
# 1. 현재 DB에 있는 시간대 확인
client = get_supabase_client()
if client:
result = client.table("solar_logs") \
.select("created_at") \
.eq("plant_id", plant_id) \
.gte("created_at", f"{today}T00:00:00+09:00") \
.lt("created_at", f"{today}T23:59:59+09:00") \
.execute()
existing_hours = set()
for rec in result.data:
hour = rec['created_at'][:13] # 2026-02-27T00 형식
existing_hours.add(hour)
print(f" 현재 DB에 있는 시간대: {len(existing_hours)}")
print(f" {sorted(existing_hours)[:5]}... (샘플)")
# 2. 시간별 데이터 크롤링
try:
print(f"\n⏳ [Hourly] 오늘 시간별 데이터 수집 중...")
if hasattr(crawler_module, 'fetch_history_hourly'):
hourly_data = crawler_module.fetch_history_hourly(plant_config, today, today)
if hourly_data:
print(f"{len(hourly_data)}개 시간별 데이터 수집 완료")
save_history(hourly_data, 'hourly')
print(f" ✅ DB 저장 완료")
else:
print(" ⚠️ 데이터 없음")
else:
print(f" ⚠️ {plant_type}는 시간별 이력 수집을 지원하지 않음")
except Exception as e:
print(f"❌ [Hourly] 에러: {e}")
import traceback
traceback.print_exc()
# 3. 일별 데이터도 업데이트
try:
print(f"\n⏳ [Daily] 오늘 일별 데이터 업데이트 중...")
if hasattr(crawler_module, 'fetch_history_daily'):
daily_data = crawler_module.fetch_history_daily(plant_config, today, today)
if daily_data:
print(f"{len(daily_data)}개 일별 데이터 수집 완료")
save_history(daily_data, 'daily')
print(f" ✅ DB 저장 완료")
else:
print(" ⚠️ 데이터 없음")
else:
print(f" ⚠️ {plant_type}는 일별 이력 수집을 지원하지 않음")
except Exception as e:
print(f"❌ [Daily] 에러: {e}")
import traceback
traceback.print_exc()
print(f"\n✅ [{plant_name}] 작업 완료\n")
def main():
target_plants = ['kremc-05', 'nrems-09']
print("\n" + "="*60)
print("🌞 오늘 데이터 보완 크롤링")
print("="*60)
for plant_id in target_plants:
cfg = get_plant_config(plant_id)
if cfg:
fill_today(cfg)
else:
print(f"❌ 설정을 찾을 수 없습니다: {plant_id}")
print("\n" + "="*60)
print("🎉 모든 작업 완료!")
print("="*60 + "\n")
if __name__ == "__main__":
main()

View File

@ -1,74 +0,0 @@
"""
API 호출 테스트 - 5호기와 9호기의 2 25 시간별 데이터 확인
"""
import requests
import json
def test_api(plant_id, plant_name, date):
url = f"https://solorpower.dadot.net/plants/{plant_id}/stats/today?date={date}"
print(f"\n{'='*70}")
print(f"🔍 [{plant_name}] API 호출: {date}")
print(f"{'='*70}")
print(f"URL: {url}\n")
try:
response = requests.get(url, timeout=10)
print(f"Status Code: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"Status: {data.get('status')}")
print(f"Plant ID: {data.get('plant_id')}")
print(f"Date: {data.get('date')}")
print(f"Count: {data.get('count')}\n")
hourly_data = data.get('data', [])
# 데이터가 있는 시간대만 출력
has_data_count = 0
print("시간별 데이터 (데이터가 있는 시간만):")
for item in hourly_data:
if item.get('has_data'):
has_data_count += 1
print(f" {item['label']:>4}: current_kw={item['current_kw']:>8.2f}, today_kwh={item['today_kwh']:>8.2f}")
if has_data_count == 0:
print(" ❌ 데이터가 있는 시간대가 없습니다!")
# 전체 응답 출력
print("\n전체 응답:")
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
print(f"\n✅ 총 {has_data_count}개 시간대에 데이터 있음")
else:
print(f"❌ API 호출 실패")
print(response.text)
except Exception as e:
print(f"❌ 에러 발생: {e}")
import traceback
traceback.print_exc()
def main():
print("\n" + "="*70)
print("🌐 API 호출 테스트")
print("="*70)
# 2월 25일 데이터 확인
test_api("kremc-05", "5호기", "2026-02-25")
test_api("nrems-09", "9호기", "2026-02-25")
# 2월 1일도 확인
test_api("kremc-05", "5호기", "2026-02-01")
test_api("nrems-09", "9호기", "2026-02-01")
print("\n" + "="*70)
print("테스트 완료")
print("="*70 + "\n")
if __name__ == "__main__":
main()

View File

@ -1,95 +0,0 @@
"""
2 데이터의 current_kw 업데이트
과거 데이터의 경우 current_kw = today_kwh (시간별 발전량) 설정
"""
import sys
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv()
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from database import get_supabase_client
def update_current_kw(plant_id, plant_name):
"""2월 데이터의 current_kw를 today_kwh로 업데이트"""
print(f"\n{'='*70}")
print(f"🔧 [{plant_name}] current_kw 업데이트 중...")
print(f"{'='*70}")
client = get_supabase_client()
if not client:
return
# 2월 1일부터 27일까지
start = datetime(2026, 2, 1)
end = datetime(2026, 2, 27)
total_updated = 0
current = start
while current <= end:
date_str = current.strftime("%Y-%m-%d")
# 해당 날짜의 모든 시간별 데이터 가져오기
result = client.table("solar_logs") \
.select("id, current_kw, today_kwh") \
.eq("plant_id", plant_id) \
.gte("created_at", f"{date_str}T00:00:00") \
.lt("created_at", f"{(current + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00") \
.execute()
if not result.data:
current += timedelta(days=1)
continue
# current_kw가 0이고 today_kwh가 0이 아닌 레코드만 업데이트
updated_count = 0
for record in result.data:
if record['current_kw'] == 0 and record['today_kwh'] != 0:
try:
# current_kw를 today_kwh로 업데이트
client.table("solar_logs") \
.update({"current_kw": record['today_kwh']}) \
.eq("id", record['id']) \
.execute()
updated_count += 1
except Exception as e:
print(f" ⚠️ 업데이트 실패 (id: {record['id']}): {e}")
if updated_count > 0:
print(f"{date_str}: {updated_count}건 업데이트")
total_updated += updated_count
current += timedelta(days=1)
print(f"\n✅ [{plant_name}] 총 {total_updated}건 업데이트 완료")
def main():
plants = [
('kremc-05', '5호기'),
('nrems-09', '9호기')
]
print("\n" + "="*70)
print("🔧 2월 데이터 current_kw 업데이트 시작")
print("="*70)
for plant_id, plant_name in plants:
update_current_kw(plant_id, plant_name)
print("\n" + "="*70)
print("🎉 current_kw 업데이트 완료!")
print("="*70 + "\n")
if __name__ == "__main__":
main()

View File

@ -1,81 +0,0 @@
"""
2 데이터 최종 검증
"""
import sys
import os
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from database import get_supabase_client
def final_check():
client = get_supabase_client()
if not client:
print("❌ Supabase 연결 실패")
return
print("\n" + "="*70)
print("📊 2월 데이터 최종 검증 결과")
print("="*70)
plants = [
('kremc-05', '5호기'),
('nrems-09', '9호기')
]
for plant_id, plant_name in plants:
print(f"\n🏭 [{plant_name}] ({plant_id})")
print("-" * 70)
# 시간별 데이터
hourly = client.table("solar_logs") \
.select("*", count='exact') \
.eq("plant_id", plant_id) \
.gte("created_at", "2026-02-01T00:00:00+09:00") \
.lte("created_at", "2026-02-27T23:59:59+09:00") \
.execute()
hourly_count = hourly.count if hasattr(hourly, 'count') else len(hourly.data)
# 일별 데이터
daily = client.table("daily_stats") \
.select("*", count='exact') \
.eq("plant_id", plant_id) \
.gte("date", "2026-02-01") \
.lte("date", "2026-02-27") \
.execute()
daily_count = daily.count if hasattr(daily, 'count') else len(daily.data)
total_gen = sum(r.get('total_generation', 0) for r in daily.data)
avg_gen = total_gen / daily_count if daily_count > 0 else 0
# 월별 통계
monthly = client.table("monthly_stats") \
.select("*") \
.eq("plant_id", plant_id) \
.eq("month", "2026-02") \
.execute()
monthly_gen = monthly.data[0].get('total_generation', 0) if monthly.data else 0
print(f" ✅ 시간별 데이터 (Hourly): {hourly_count}")
print(f" ✅ 일별 데이터 (Daily): {daily_count}")
print(f" 📈 2월 총 발전량: {total_gen:,.2f} kWh")
print(f" 📈 일평균 발전량: {avg_gen:,.2f} kWh/day")
print(f" 📊 월별 통계: {monthly_gen:,.2f} kWh")
print("\n" + "="*70)
print("✅ 모든 데이터가 Supabase DB에 정상 저장되었습니다!")
print("="*70 + "\n")
if __name__ == "__main__":
final_check()

View File

@ -1,180 +0,0 @@
"""
2 데이터 검증 스크립트
5호기(kremc-05), 9호기(nrems-09) 2 데이터가 DB에 제대로 저장되었는지 확인
"""
import sys
import os
from datetime import datetime
from dotenv import load_dotenv
# .env 로드
load_dotenv()
# Windows 인코딩 문제 해결
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# 프로젝트 루트 경로 추가
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from database import get_supabase_client
def verify_data(plant_id, plant_name):
"""특정 발전소의 2월 데이터 검증"""
print(f"\n{'='*70}")
print(f"🔍 [{plant_name}] 2월 데이터 검증 중...")
print(f"{'='*70}")
client = get_supabase_client()
if client is None:
print("❌ Supabase 연결 실패")
return
# 2월 데이터 범위 설정
now = datetime.now()
year = now.year
start_date = f"{year}-02-01"
if now.month == 2:
end_date = now.strftime("%Y-%m-%d")
else:
# 2월 마지막 날
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
end_date = f"{year}-02-29"
else:
end_date = f"{year}-02-28"
month_str = f"{year}-02"
try:
# 1. 시간별 데이터 확인 (solar_logs)
print(f"\n📊 [Hourly] 시간별 데이터 (solar_logs)")
print(f" 조회 기간: {start_date} ~ {end_date}")
hourly_result = client.table("solar_logs") \
.select("*", count='exact') \
.eq("plant_id", plant_id) \
.gte("created_at", f"{start_date}T00:00:00+09:00") \
.lte("created_at", f"{end_date}T23:59:59+09:00") \
.order("created_at", desc=False) \
.execute()
hourly_count = hourly_result.count if hasattr(hourly_result, 'count') else len(hourly_result.data)
if hourly_count > 0:
print(f" ✅ 총 {hourly_count}건의 시간별 데이터 발견")
# 날짜별 카운트 집계
dates = {}
total_kwh = 0
for record in hourly_result.data:
date_str = record['created_at'][:10]
dates[date_str] = dates.get(date_str, 0) + 1
total_kwh += record.get('today_kwh', 0)
print(f" 📅 {len(dates)}일간의 데이터")
# 처음 3일과 마지막 3일 샘플 표시
sorted_dates = sorted(dates.keys())
print(f"\n [샘플 - 처음 3일]")
for d in sorted_dates[:3]:
print(f" {d}: {dates[d]}")
if len(sorted_dates) > 6:
print(f" ... ({len(sorted_dates) - 6}일 생략) ...")
print(f"\n [샘플 - 마지막 3일]")
for d in sorted_dates[-3:]:
print(f" {d}: {dates[d]}")
print(f"\n 💡 평균 발전량 합계: {total_kwh / len(hourly_result.data):.2f} kWh/시간")
else:
print(f" ⚠️ 시간별 데이터가 없습니다!")
# 2. 일별 데이터 확인 (daily_stats)
print(f"\n📊 [Daily] 일별 데이터 (daily_stats)")
print(f" 조회 기간: {start_date} ~ {end_date}")
daily_result = client.table("daily_stats") \
.select("*", count='exact') \
.eq("plant_id", plant_id) \
.gte("date", start_date) \
.lte("date", end_date) \
.order("date", desc=False) \
.execute()
daily_count = daily_result.count if hasattr(daily_result, 'count') else len(daily_result.data)
if daily_count > 0:
print(f" ✅ 총 {daily_count}건의 일별 데이터 발견")
total_generation = sum(r.get('total_generation', 0) for r in daily_result.data)
avg_generation = total_generation / daily_count if daily_count > 0 else 0
print(f" 📈 2월 총 발전량: {total_generation:.2f} kWh")
print(f" 📈 일평균 발전량: {avg_generation:.2f} kWh")
# 처음 5일과 마지막 5일 샘플 표시
print(f"\n [샘플 - 처음 5일]")
for record in daily_result.data[:5]:
print(f" {record['date']}: {record.get('total_generation', 0):.2f} kWh")
if len(daily_result.data) > 10:
print(f" ... ({len(daily_result.data) - 10}일 생략) ...")
print(f"\n [샘플 - 마지막 5일]")
for record in daily_result.data[-5:]:
print(f" {record['date']}: {record.get('total_generation', 0):.2f} kWh")
else:
print(f" ⚠️ 일별 데이터가 없습니다!")
# 3. 월별 데이터 확인 (monthly_stats)
print(f"\n📊 [Monthly] 2월 월별 데이터 (monthly_stats)")
print(f" 조회 월: {month_str}")
monthly_result = client.table("monthly_stats") \
.select("*") \
.eq("plant_id", plant_id) \
.eq("month", month_str) \
.execute()
if monthly_result.data:
record = monthly_result.data[0]
print(f" ✅ 2월 월별 통계 발견")
print(f" 📈 총 발전량: {record.get('total_generation', 0):.2f} kWh")
print(f" 🕐 업데이트: {record.get('updated_at', 'N/A')}")
else:
print(f" ⚠️ 2월 월별 데이터가 없습니다!")
print(f"\n✅ [{plant_name}] 검증 완료\n")
except Exception as e:
print(f"❌ 검증 중 오류 발생: {e}")
import traceback
traceback.print_exc()
def main():
"""메인 실행 함수"""
plants = [
('kremc-05', '5호기'),
('nrems-09', '9호기')
]
print("\n" + "="*70)
print("🔍 2월 데이터 검증 시작")
print("="*70)
for plant_id, plant_name in plants:
verify_data(plant_id, plant_name)
print("="*70)
print("🎉 모든 검증 완료!")
print("="*70 + "\n")
if __name__ == "__main__":
main()

View File

@ -1,91 +0,0 @@
# ==========================================
# sync_plants.py - 발전소 정보 동기화
# ==========================================
# config.py의 발전소 정보를 Supabase plants 테이블에 Upsert
from datetime import datetime
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from config import get_all_plants
from database import get_supabase_client
def sync_plants():
"""
로컬 config.py의 발전소 정보를 Supabase plants 테이블에 동기화
"""
print(f"\n🔄 [발전소 동기화] 시작... ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})")
print("-" * 60)
client = get_supabase_client()
if not client:
print("❌ Supabase 연결 실패")
return False
plants = get_all_plants()
# 중복 제거 (is_split인 1,2호기는 별도 처리)
unique_plants = {}
for plant in plants:
plant_id = plant.get('id', '')
is_split = plant.get('options', {}).get('is_split', False)
if is_split:
# 1, 2호기 분리 (용량 N빵)
total_capacity = plant.get('capacity_kw', 100.0)
unit_capacity = total_capacity / 2
start_date = plant.get('start_date', '')
unique_plants['nrems-01'] = {
'id': 'nrems-01',
'name': f"{plant.get('company_name', '')} 1호기",
'type': plant.get('type', ''),
'capacity': unit_capacity,
'constructed_at': start_date,
'company_id': 1
}
unique_plants['nrems-02'] = {
'id': 'nrems-02',
'name': f"{plant.get('company_name', '')} 2호기",
'type': plant.get('type', ''),
'capacity': unit_capacity,
'constructed_at': start_date,
'company_id': 1
}
elif plant_id:
unique_plants[plant_id] = {
'id': plant_id,
'name': f"{plant.get('company_name', '')} {plant.get('name', '')}",
'type': plant.get('type', ''),
'capacity': plant.get('capacity_kw', 0.0),
'constructed_at': plant.get('start_date', ''),
'company_id': 1
}
success_count = 0
for plant_id, plant_data in unique_plants.items():
try:
result = client.table("plants").upsert(
plant_data,
on_conflict="id"
).execute()
print(f"{plant_data['name']} (용량: {plant_data['capacity']} kW)")
success_count += 1
except Exception as e:
print(f"{plant_id} 실패: {e}")
print("-" * 60)
print(f"✅ 동기화 완료: {success_count}/{len(unique_plants)}")
return True
if __name__ == "__main__":
sync_plants()

View File

@ -1,128 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
1 28, 29 데이터 확인 스크립트
"""
from datetime import datetime
import sys
import os
print("Starting checks...", flush=True)
# Add parent directory to path to import modules
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
sys.path.append(parent_dir)
print(f"Current dir: {current_dir}", flush=True)
print(f"Parent dir: {parent_dir}", flush=True)
print(f"Sys path: {sys.path}", flush=True)
try:
from crawlers import nrems, hyundai, kremc, sun_wms, cmsolar
from config import SYSTEM_CONSTANTS
print("Imports successful", flush=True)
except Exception as e:
print(f"Import failed: {e}", flush=True)
import traceback
traceback.print_exc()
sys.exit(1)
def check_dates(plant_config, crawler_module, start_date, end_date):
plant_name = plant_config['name']
print(f"\n[{plant_name}] 데이터 확인: {start_date} ~ {end_date}")
try:
# Check daily data
daily_data = crawler_module.fetch_history_daily(plant_config, start_date, end_date)
if not daily_data:
print(" ❌ 데이터 없음")
return
print(f"{len(daily_data)}일 데이터 수신")
for record in daily_data:
print(f" - 날짜: {record.get('date', 'Unknown')}, 발전량: {record.get('generation_kwh', 0)} kWh")
except Exception as e:
print(f" ❌ 오류 발생: {str(e)}")
# import traceback
# traceback.print_exc()
def main():
print(">>> 1월 28, 29일 데이터 확인 <<<")
# Dates to check
start_date = '2026-01-28'
end_date = '2026-01-29'
test_plants = [
# NREMS 1,2호기 (분리)
({'id': 'nrems-01', 'name': '1호기', 'type': 'nrems',
'auth': {'pscode': 'duce2023072288'},
'options': {'is_split': True, 'unit_id': 1},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
({'id': 'nrems-02', 'name': '2호기', 'type': 'nrems',
'auth': {'pscode': 'duce2023072288'},
'options': {'is_split': True, 'unit_id': 2},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# NREMS 3호기
({'id': 'nrems-03', 'name': '3호기', 'type': 'nrems',
'auth': {'pscode': 'dc2023121086'},
'options': {},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# NREMS 4호기
({'id': 'nrems-04', 'name': '4호기', 'type': 'nrems',
'auth': {'pscode': 'duce2023072269'},
'options': {},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# NREMS 9호기
({'id': 'nrems-09', 'name': '9호기', 'type': 'nrems',
'auth': {'pscode': 'a2020061008'},
'options': {},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# KREMC 5호기
({'id': 'kremc-05', 'name': '5호기', 'type': 'kremc',
'auth': {'user_id': '서대문도서관', 'password': 'sunhope5!'},
'options': {'cid': '10013000376', 'cityProvCode': '11', 'rgnCode': '11410',
'dongCode': '1141011700', 'enso_type_code': '15001'},
'system': SYSTEM_CONSTANTS['kremc']}, kremc),
# Sun-WMS 6호기
({'id': 'sunwms-06', 'name': '6호기', 'type': 'sun_wms',
'auth': {'payload_id': 'kc0fXUW0LUm2wZa+2NQI0Q==', 'payload_pw': 'PGXjU6ib2mKYwtrh2i3fIQ=='},
'options': {},
'system': SYSTEM_CONSTANTS['sun_wms']}, sun_wms),
# Hyundai 8호기
({'id': 'hyundai-08', 'name': '8호기', 'type': 'hyundai',
'auth': {'user_id': 'epecoop', 'password': 'sunhope0419', 'site_id': 'M0494'},
'options': {},
'system': SYSTEM_CONSTANTS['hyundai']}, hyundai),
# CMSolar 10호기 (Fix login info from verify_data.py if valid, otherwise use config.py's)
# Using config.py's info but updated with values seen in verify_data.py which seemed to be used for testing
# verify_data.py had: 'login_id': 'smart3131', 'password': 'ehdrb!123'
# config.py has: 'login_id': 'sy7144', 'login_pw': 'sy7144'
# I should probably use what is in config.py OR verify_data.py. Let's try config.py first as it is the source of truth usually,
# BUT wait, verify_data.py was likely used recently.
# Let's check config.py again. Config.py has 'sy7144'. verify_data.py has 'smart3131'.
# The user history mentioned "Debugging Real-time Crawlers" and "CMSolar".
# Let's check `crawler/crawlers/cmsolar.py` to see what it expects or if there are hardcoded overrides.
({'id': 'cmsolar-10', 'name': '10호기', 'type': 'cmsolar',
'auth': {'login_id': 'sy7144', 'login_pw': 'sy7144', 'site_no': '834'},
'options': {},
'system': SYSTEM_CONSTANTS['cmsolar']}, cmsolar),
]
for plant_config, crawler_module in test_plants:
check_dates(plant_config, crawler_module, start_date, end_date)
if __name__ == '__main__':
main()

View File

@ -1,26 +0,0 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
from database import get_supabase_client
from datetime import datetime, timezone, timedelta
def check_today():
c = get_supabase_client()
# Today in KST
kst = timezone(timedelta(hours=9))
now = datetime.now(kst)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
print(f"Checking data since {today_start.isoformat()} (KST)")
res = c.table('solar_logs').select('created_at, current_kw, today_kwh, status').eq('plant_id', 'cmsolar-10').gte('created_at', today_start.isoformat()).order('created_at', desc=True).execute()
print(f"Found {len(res.data)} records for today:")
for item in res.data:
print(f"{item['created_at']} | {item.get('current_kw')} kW")
if __name__ == "__main__":
check_today()

View File

@ -1,51 +0,0 @@
import requests
from config import get_all_plants
from crawlers.cmsolar import fetch_data
from crawlers.base import create_session
def debug_cmsolar():
plants = get_all_plants()
target = next((p for p in plants if p['id'] == 'cmsolar-10'), None)
if not target:
print("Plant 10 not found")
return
print(f"Debug target: {target['name']}")
# Manually reproduce fetch_data logic to see raw response
auth = target.get('auth', {})
system = target.get('system', {})
login_id = auth.get('login_id', '') # config.py uses login_id? checking cmsolar.py it uses payload_id or auth get directly.
# config.py for cmsolar-10:
# 'auth': { 'login_id': 'sy7144', 'login_pw': 'sy7144', 'site_no': '834' }
# cmsolar.py fetch_data:
# login_id = auth.get('payload_id', '') -> THIS MIGHT BE WRONG if config keys are login_id
# Check config.py again for cmsolar-10 auth keys.
# Lines 154-158 in config.py:
# 'auth': { 'login_id': 'sy7144', 'login_pw': 'sy7144', 'site_no': '834' }
# cmsolar.py Lines 20-22:
# login_id = auth.get('payload_id', '')
# login_pw = auth.get('payload_pw', '')
# site_no = auth.get('site_no', '')
# WAIT! 'payload_id' vs 'login_id'.
# If the code expects 'payload_id' but config provides 'login_id', then login_id will be empty string.
# This might be the bug.
print(f"Auth keys in config: {list(auth.keys())}")
# Let's try to run fetch_data and catch exception
try:
result = fetch_data(target)
print(f"Result: {result}")
except Exception as e:
print(f"Exception: {e}")
if __name__ == "__main__":
debug_cmsolar()

View File

@ -1,85 +0,0 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
from config import get_all_plants
from crawlers.base import create_session
def debug_cmsolar_realtime():
plants = get_all_plants()
target = next((p for p in plants if p['id'] == 'cmsolar-10'), None)
if not target:
print("Plant 10 not found")
return
print(f"Debug target: {target['name']}")
# Extract info
auth = target.get('auth', {})
system = target.get('system', {})
login_id = auth.get('login_id', '')
login_pw = auth.get('login_pw', '')
site_no = auth.get('site_no', '')
login_url = system.get('login_url', '')
data_url = system.get('data_url', '')
print(f"Login ID: {login_id}")
print(f"Login URL: {login_url}")
print(f"Data URL: {data_url}")
session = create_session()
headers = {
'User-Agent': 'Mozilla/5.0',
'Content-Type': 'application/x-www-form-urlencoded'
}
# Login
login_data = {
'login_id': login_id,
'login_pw': login_pw,
'site_no': site_no
}
print("Logging in...")
try:
res = session.post(login_url, data=login_data, headers=headers)
print(f"Login Status: {res.status_code}")
# Site selection
base_url = "http://www.cmsolar2.kr"
change_url = f"{base_url}/change.php?site={site_no}"
print(f"Selecting site via {change_url}...")
session.get(change_url, headers=headers)
except Exception as e:
print(f"Login/Select Error: {e}")
return
# Fetch Data
real_data_url = f"{base_url}/plant/sub/idx_ok.php?mode=getPlant"
print(f"Fetching data from {real_data_url}...")
try:
res = session.get(real_data_url, headers=headers)
print(f"Data Status: {res.status_code}")
# print(f"Data Content-Type: {res.headers.get('Content-Type')}")
print(f"Data Response:\n{res.text}")
try:
json_data = res.json()
print(f"JSON parsed successfully.")
except Exception as e:
print(f"JSON Parse Error: {e}")
except Exception as e:
print(f"Data Fetch Error: {e}")
if __name__ == "__main__":
debug_cmsolar_realtime()

View File

@ -1,14 +0,0 @@
from dotenv import load_dotenv
load_dotenv()
from database import get_supabase_client
def check_db():
c = get_supabase_client()
res = c.table('solar_logs').select('created_at, current_kw, today_kwh').eq('plant_id', 'cmsolar-10').order('created_at', desc=True).limit(30).execute()
print("Recent logs for cmsolar-10:")
for item in res.data:
print(f"{item['created_at']} | {item.get('current_kw', 'N/A')} kW | {item.get('today_kwh', 'N/A')} kWh")
if __name__ == "__main__":
check_db()

View File

@ -1,43 +0,0 @@
import requests
from dotenv import load_dotenv
load_dotenv()
from config import get_all_plants
from crawlers.kremc import fetch_data
from crawlers.base import create_session
def debug_kremc():
plants = get_all_plants()
# 5호기 (kremc) 찾기 - id가 kremc-05인 것
target = next((p for p in plants if p['id'] == 'kremc-05'), None)
if not target:
print("Plant kremc-05 not found")
return
print(f"Debug target: {target['name']}")
print(f"Debug target: {target['name']}")
from datetime import datetime
today = datetime.now().strftime('%Y-%m-%d')
print(f"Fetching hourly history for {today}...")
from crawlers.kremc import fetch_history_hourly
from database import save_history
try:
results = fetch_history_hourly(target, today, today)
print(f"Hourly Results ({len(results)}):")
for r in results:
print(f" {r['timestamp']}: {r['generation_kwh']} kWh")
if results:
print("Saving to DB...")
save_history(results, 'hourly')
print("Done.")
except Exception as e:
print(f"Exception: {e}")
if __name__ == "__main__":
debug_kremc()

View File

@ -1,30 +0,0 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
from config import get_all_plants
from crawlers.kremc import fetch_data
def debug_kremc_realtime():
plants = get_all_plants()
target = next((p for p in plants if p['id'] == 'kremc-05'), None)
if not target:
print("Plant 5 not found")
return
print(f"Debug target: {target['name']}")
try:
print("Fetching data...")
results = fetch_data(target)
print(f"Results: {results}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
debug_kremc_realtime()

View File

@ -1,118 +0,0 @@
import sys
import os
import importlib
from datetime import datetime, timezone, timedelta
# Add parent directory to path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
from database import get_supabase_client, save_history
from config import get_all_plants
def cleanup_history_today(plant_id, today_str):
"""
Cleans up 'History' status records for the target date to avoid duplicates.
"""
client = get_supabase_client()
if not client:
return
# Delete records with status='History' created within the target date range
# Since created_at is timestampz, we need to be careful.
# But usually save_history sets created_at to the actual data timestamp for hourly history.
# Or does it?
# In 'save_history' (database.py): records.append({ ..., 'created_at': final_created_at, ... })
# where final_created_at comes from the data timestamp.
# So we should delete range [today 00:00:00, today 23:59:59]
start_ts = f"{today_str}T00:00:00"
end_ts = f"{today_str}T23:59:59"
try:
# We also filter by status='History' to avoid deleting real-time crawled logs (if any exist)
# Real-time logs usually have status='Normal' or 'Abnormal' or empty.
# History fetch sets status='History'.
res = client.table('solar_logs').delete() \
.eq('plant_id', plant_id) \
.eq('status', 'History') \
.gte('created_at', start_ts) \
.lte('created_at', end_ts) \
.execute()
if res.data:
print(f" 🧹 Cleaned up {len(res.data)} old history records for {today_str}.")
except Exception as e:
print(f" ⚠️ Cleanup failed: {e}")
def fill_all_today():
plants = get_all_plants()
now_kst = datetime.now(timezone(timedelta(hours=9)))
today_str = now_kst.strftime("%Y-%m-%d")
print(f"🚀 Starting Manual Data Fetch for TODAY: {today_str}")
print("=" * 60)
for plant in plants:
plant_id = plant['id']
plant_name = plant['name']
plant_type = plant['type']
# Skip unknown or unsupported types
if plant_type == 'unknown':
continue
print(f"\nProcessing [{plant_type.upper()}] {plant_name} ({plant_id})...")
try:
# Dynamic import
module = importlib.import_module(f"crawlers.{plant_type}")
# 1. Hourly Data
if hasattr(module, 'fetch_history_hourly'):
print(" ⏳ Fetching Hourly Data...")
# Cleanup previous 'History' data for today to prevent dups
cleanup_history_today(plant_id, today_str)
try:
# fetch_history_hourly(config, start_date, end_date)
data = module.fetch_history_hourly(plant, today_str, today_str)
if data:
# save_history handles 'hourly' -> inserts into solar_logs
save_history(data, 'hourly')
else:
print(" ⚠️ No Hourly data found.")
except Exception as e:
print(f" ❌ Hourly Fetch Error: {e}")
else:
print(" No fetch_history_hourly method.")
# 2. Daily Data (Optional, as it might not be ready yet)
if hasattr(module, 'fetch_history_daily'):
print(" ⏳ Fetching Daily Data...")
try:
# fetch_history_daily(config, start_date, end_date)
data = module.fetch_history_daily(plant, today_str, today_str)
if data:
# save_history handles 'daily' -> upserts daily_stats & updates monthly
save_history(data, 'daily')
else:
print(" ⚠️ No Daily data found (Site might not list today yet).")
except Exception as e:
print(f" ❌ Daily Fetch Error: {e}")
else:
print(" No fetch_history_daily method.")
except ImportError:
print(f" ❌ Module 'crawlers.{plant_type}' not found.")
except Exception as e:
print(f" ❌ Error processing plant: {e}")
print("\n" + "=" * 60)
print("All tasks completed.")
if __name__ == "__main__":
fill_all_today()

View File

@ -1,53 +0,0 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
from datetime import datetime
from database import get_supabase_client, save_history
from config import get_all_plants
from crawlers.kremc import fetch_history_hourly as fetch_kremc
from crawlers.cmsolar import fetch_history_hourly as fetch_cmsolar
def cleanup_history(plant_id, today_str):
client = get_supabase_client()
# Delete 'History' status records for today to avoid duplicates/bad data
# Filter by created_at >= today's start and status='History'
# Simple approach: delete records with status='History' created today
# KST date string is tricky for created_at (UTC), but status='History' is unique to our manual script
try:
res = client.table('solar_logs').delete().eq('plant_id', plant_id).eq('status', 'History').execute()
print(f"[{plant_id}] Cleaned up {len(res.data)} old history records.")
except Exception as e:
print(f"[{plant_id}] Cleanup failed (might be empty): {e}")
def fill_today_data():
plants = get_all_plants()
kremc_plant = next((p for p in plants if p['id'] == 'kremc-05'), None)
cmsolar_plant = next((p for p in plants if p['id'] == 'cmsolar-10'), None)
today = "2026-01-29"
print(f"Filling data for {today}...")
# 1. KREMC (5호기) - Skip as it's done
# if kremc_plant: ...
# 2. CMSolar (10호기)
if cmsolar_plant:
print("\n--- Processing CMSolar (10호기) ---")
cleanup_history('cmsolar-10', today)
try:
results = fetch_cmsolar(cmsolar_plant, today, today)
print(f"Fetched results: {results}")
if results:
save_history(results, 'hourly')
print("Saved CMSolar data.")
except Exception as e:
print(f"CMSolar Error: {e}")
if __name__ == "__main__":
fill_today_data()

View File

@ -1,64 +0,0 @@
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는 서버에 있음)")

View File

@ -1,66 +0,0 @@
import sys
from pathlib import Path
# Add parent directory to sys.path to allow importing from root
sys.path.append(str(Path(__file__).parent.parent))
from dotenv import load_dotenv
# 로드 환경 변수 (database 임포트 전에 실행)
load_dotenv()
from database import get_supabase_client
from datetime import datetime, timedelta
def check_db_data():
client = get_supabase_client()
if not client:
print("❌ Supabase connection failed")
return
# Check data from yesterday 18:00 to 20:00
# Note: DB stores in KST or UTC?
# recover_data.py used KST time in 'created_at' string.
# Let's query based on string range.
# KST 18:00 - 20:00 is UTC 09:00 - 11:00
start_time = "2026-02-12 09:00:00"
end_time = "2026-02-12 11:15:00"
print(f"🔍 Checking DB data from {start_time} to {end_time} (UTC)...")
try:
response = client.table("solar_logs").select("*") \
.gte("created_at", start_time) \
.lte("created_at", end_time) \
.order("created_at") \
.execute()
data = response.data
if not data:
print("⚠️ No data found in this range.")
return
print(f"✅ Found {len(data)} records.\n")
# Group by timestamp to see snapshot completeness
timestamps = {}
for item in data:
ts = item['created_at']
if ts not in timestamps:
timestamps[ts] = []
timestamps[ts].append(item)
for ts in sorted(timestamps.keys()):
items = timestamps[ts]
print(f"{ts} - {len(items)} plants")
for item in items:
print(f" - {item['plant_id']}: {item['current_kw']} kW / {item['today_kwh']} kWh")
print("-" * 50)
except Exception as e:
print(f"❌ Error querying DB: {e}")
if __name__ == "__main__":
check_db_data()

View File

@ -1,142 +0,0 @@
import sys
from pathlib import Path
# Add parent directory to sys.path to allow importing from root
sys.path.append(str(Path(__file__).parent.parent))
import os
import re
from datetime import datetime
from dotenv import load_dotenv
# 로드 환경 변수
load_dotenv()
from database import get_supabase_client, save_history
PLANT_MAP = {
"태양과바람 1호기": "nrems-01",
"태양과바람 2호기": "nrems-02",
"태양과바람 3호기": "nrems-03",
"태양과바람 4호기": "nrems-04",
"태양과바람 5호기": "kremc-05",
"태양과바람 6호기": "sunwms-06",
"태양과바람 8호기": "hyundai-08",
"태양과바람 9호기": "nrems-09",
"태양과바람 10호기": "cmsolar-10"
}
def clean_and_recover(log_path, start_time_str, end_time_str):
"""
1. Removes bad data (where current_kw == generation_kwh but current_kw should be 0)
Or simpler: remove ALL hourly data for the period and re-insert.
2. Parses log and re-inserts data.
"""
print(f"🧹 Cleaning DB data from {start_time_str} to {end_time_str}...")
# Convert local times to UTC range for deletion query
# But wait, save_history sends timezone-aware timestamp (+09:00).
# Supabase stores as UTC.
# To delete, we can use the same string range if we are careful, or convert.
# The safest way is to target the range.
# 1. Delete existing records in the range
client = get_supabase_client()
if not client:
return
# KST to UTC conversion for query
# 2026-02-12 17:00:00 KST -> 08:00 UTC
# 2026-02-13 10:00:00 KST -> 01:00 UTC (next day)
try:
start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
end_dt = datetime.strptime(end_time_str, "%Y-%m-%d %H:%M:%S")
# UTC subtract 9 hours
from datetime import timedelta
start_utc = (start_dt - timedelta(hours=9)).isoformat()
end_utc = (end_dt - timedelta(hours=9)).isoformat()
print(f" Deleting range (UTC): {start_utc} ~ {end_utc}")
# Delete solar_logs
res = client.table("solar_logs").delete() \
.gte("created_at", start_utc) \
.lte("created_at", end_utc) \
.execute()
print(f"✅ Deleted {len(res.data) if res.data else '0'} records.")
except Exception as e:
print(f"❌ Deletion failed: {e}")
# Proceed to insert anyway? Duplicates might occur if delete failed.
print(f"📂 Parsing log: {log_path}")
start_pattern = re.compile(r"통합 관제 시스템.*\((\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\)")
table_pattern = re.compile(r"(태양과바람 \d+호기)\s+\|\s+([\d.]+)\s+\|\s+([\d.]+)\s+\|")
current_timestamp = None
recovered_data = []
try:
with open(log_path, 'r', encoding='utf-8') as f:
for line in f:
start_match = start_pattern.search(line)
if start_match:
ts_str = start_match.group(1)
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if start_dt <= ts_dt <= end_dt:
current_timestamp = ts_str
else:
current_timestamp = None
continue
if current_timestamp:
table_match = table_pattern.search(line)
if table_match:
plant_name = table_match.group(1).strip()
kw = float(table_match.group(2))
kwh = float(table_match.group(3))
plant_id = PLANT_MAP.get(plant_name)
if plant_id:
recovered_data.append({
'plant_id': plant_id,
'timestamp': current_timestamp,
'current_kw': kw, # Now database.py handles 0.0 correctly
'generation_kwh': kwh
})
except Exception as e:
print(f"❌ Error parsing log: {e}")
return
print(f"✅ Found {len(recovered_data)} points to restore.")
if not recovered_data:
return
chunk_size = 100
total_saved = 0
for i in range(0, len(recovered_data), chunk_size):
chunk = recovered_data[i:i + chunk_size]
if save_history(chunk, 'hourly'):
total_saved += len(chunk)
else:
print("❌ Insert failed")
print(f"🎉 Recovery finished. {total_saved} records inserted.")
# 2. Daily stats update (optional, but safe to do)
# ... (omitted for brevity, hourly is critical data)
if __name__ == "__main__":
log_file = r"d:\dev\etc\SolorPower\crawler\log\cron.log"
# Target period: Yesterday 17:00 ~ Today 10:00
start = "2026-02-12 17:00:00"
end = "2026-02-13 10:00:00"
clean_and_recover(log_file, start, end)

View File

@ -1,502 +0,0 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
# SIG # Begin signature block
# MIIvJAYJKoZIhvcNAQcCoIIvFTCCLxECAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBnL745ElCYk8vk
# dBtMuQhLeWJ3ZGfzKW4DHCYzAn+QB6CCE8MwggWQMIIDeKADAgECAhAFmxtXno4h
# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z
# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z
# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ
# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s
# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL
# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb
# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3
# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c
# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx
# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0
# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL
# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud
# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf
# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk
# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS
# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK
# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB
# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp
# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg
# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri
# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7
# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5
# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3
# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H
# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G
# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C
# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce
# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da
# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T
# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA
# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh
# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM
# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z
# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05
# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY
# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP
# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN
# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry
# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL
# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf
# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh
# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh
# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV
# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j
# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH
# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC
# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l
# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW
# eE4wggd3MIIFX6ADAgECAhAHHxQbizANJfMU6yMM0NHdMA0GCSqGSIb3DQEBCwUA
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwHhcNMjIwMTE3MDAwMDAwWhcNMjUwMTE1MjM1OTU5WjB8MQsw
# CQYDVQQGEwJVUzEPMA0GA1UECBMGT3JlZ29uMRIwEAYDVQQHEwlCZWF2ZXJ0b24x
# IzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9uMSMwIQYDVQQDExpQ
# eXRob24gU29mdHdhcmUgRm91bmRhdGlvbjCCAiIwDQYJKoZIhvcNAQEBBQADggIP
# ADCCAgoCggIBAKgc0BTT+iKbtK6f2mr9pNMUTcAJxKdsuOiSYgDFfwhjQy89koM7
# uP+QV/gwx8MzEt3c9tLJvDccVWQ8H7mVsk/K+X+IufBLCgUi0GGAZUegEAeRlSXx
# xhYScr818ma8EvGIZdiSOhqjYc4KnfgfIS4RLtZSrDFG2tN16yS8skFa3IHyvWdb
# D9PvZ4iYNAS4pjYDRjT/9uzPZ4Pan+53xZIcDgjiTwOh8VGuppxcia6a7xCyKoOA
# GjvCyQsj5223v1/Ig7Dp9mGI+nh1E3IwmyTIIuVHyK6Lqu352diDY+iCMpk9Zanm
# SjmB+GMVs+H/gOiofjjtf6oz0ki3rb7sQ8fTnonIL9dyGTJ0ZFYKeb6BLA66d2GA
# LwxZhLe5WH4Np9HcyXHACkppsE6ynYjTOd7+jN1PRJahN1oERzTzEiV6nCO1M3U1
# HbPTGyq52IMFSBM2/07WTJSbOeXjvYR7aUxK9/ZkJiacl2iZI7IWe7JKhHohqKuc
# eQNyOzxTakLcRkzynvIrk33R9YVqtB4L6wtFxhUjvDnQg16xot2KVPdfyPAWd81w
# tZADmrUtsZ9qG79x1hBdyOl4vUtVPECuyhCxaw+faVjumapPUnwo8ygflJJ74J+B
# Yxf6UuD7m8yzsfXWkdv52DjL74TxzuFTLHPyARWCSCAbzn3ZIly+qIqDAgMBAAGj
# ggIGMIICAjAfBgNVHSMEGDAWgBRoN+Drtjv4XxGG+/5hewiIZfROQjAdBgNVHQ4E
# FgQUt/1Teh2XDuUj2WW3siYWJgkZHA8wDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM
# MAoGCCsGAQUFBwMDMIG1BgNVHR8Ega0wgaowU6BRoE+GTWh0dHA6Ly9jcmwzLmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
# QTM4NDIwMjFDQTEuY3JsMFOgUaBPhk1odHRwOi8vY3JsNC5kaWdpY2VydC5jb20v
# RGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0Ex
# LmNybDA+BgNVHSAENzA1MDMGBmeBDAEEATApMCcGCCsGAQUFBwIBFhtodHRwOi8v
# d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUF
# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6
# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWdu
# aW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZI
# hvcNAQELBQADggIBABxv4AeV/5ltkELHSC63fXAFYS5tadcWTiNc2rskrNLrfH1N
# s0vgSZFoQxYBFKI159E8oQQ1SKbTEubZ/B9kmHPhprHya08+VVzxC88pOEvz68nA
# 82oEM09584aILqYmj8Pj7h/kmZNzuEL7WiwFa/U1hX+XiWfLIJQsAHBla0i7QRF2
# de8/VSF0XXFa2kBQ6aiTsiLyKPNbaNtbcucaUdn6vVUS5izWOXM95BSkFSKdE45O
# q3FForNJXjBvSCpwcP36WklaHL+aHu1upIhCTUkzTHMh8b86WmjRUqbrnvdyR2yd
# I5l1OqcMBjkpPpIV6wcc+KY/RH2xvVuuoHjlUjwq2bHiNoX+W1scCpnA8YTs2d50
# jDHUgwUo+ciwpffH0Riq132NFmrH3r67VaN3TuBxjI8SIZM58WEDkbeoriDk3hxU
# 8ZWV7b8AW6oyVBGfM06UgkfMb58h+tJPrFx8VI/WLq1dTqMfZOm5cuclMnUHs2uq
# rRNtnV8UfidPBL4ZHkTcClQbCoz0UbLhkiDvIS00Dn+BBcxw/TKqVL4Oaz3bkMSs
# M46LciTeucHY9ExRVt3zy7i149sd+F4QozPqn7FrSVHXmem3r7bjyHTxOgqxRCVa
# 18Vtx7P/8bYSBeS+WHCKcliFCecspusCDSlnRUjZwyPdP0VHxaZg2unjHY3rMYIa
# tzCCGrMCAQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
# Yy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJT
# QTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhAHHxQbizANJfMU6yMM0NHdMA0GCWCGSAFl
# AwQCAQUAoIHIMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcC
# AQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCBnAZ6P7YvTwq0fbF62
# o7E75R0LxsW5OtyYiFESQckLhjBcBgorBgEEAYI3AgEMMU4wTKBGgEQAQgB1AGkA
# bAB0ADoAIABSAGUAbABlAGEAcwBlAF8AdgAzAC4AMQAxAC4ANQBfADIAMAAyADMA
# MAA4ADIANAAuADAAMaECgAAwDQYJKoZIhvcNAQEBBQAEggIAhs4bX7EeJZ6oSTC7
# 5QH/9Qx1Cllidnzj94PqDIL0MiS5adMxYTBkEtP3XNQvYkCtBFc6+Rz7bdN+zWWo
# ZYr+sDmEQmRerr3RYyqt+EpgOXpN4BGsHyD7r1Dat1wblGSva8zlOHiIAfzRj2JB
# 0+fRJPSBRj9RYwZb5h+I2AFLmHf3yItUdgs8GV9NZsAs+p79dRmoqhgNC6qm8I0j
# PkwGr5ATZLyCk2U1+VGeK8iwAdTB4HAlVVM146D/34j/QPnoqe9ICE6Foo6IArVV
# CbqWRShWHffvpKaR7ACoTy9LoIQf93orWoc+amQsyaUmlV/zQaCnyjc2UoFCDHcH
# 87Yg+frSB8xe2azuKUTVlUDx9Y5wOtEgK+o8wg4ufwPZP0JnsVzN06aCNBz2Bnfb
# Mb96Mp0PoCnjp8eAKttmRTXWE0DYIv/XAr2xwwJLFEUdoG6bj0bpNF7Wz0/c3mi0
# NKZsd9xNLKBKjizQgCZ7SGCMuSjEnd6P0AI7M8jRx+NROKcJI6gjH0oKXm9JLvI8
# oKB2COIlxKEUI/R/kBOeKp53zUSsPFRiJrDEkiCFocAFdUTE326b9/acGbQPJJJ+
# nMXLrbTrMMohlj7qRshvO0ZVvpqBDoHlRQcJcfINEESgKNKx/bTpr5cuM3WIS5Ft
# 1GirQp9sABVeBom9Y0NDFXsCSkqhghdAMIIXPAYKKwYBBAGCNwMDATGCFywwghco
# BgkqhkiG9w0BBwKgghcZMIIXFQIBAzEPMA0GCWCGSAFlAwQCAQUAMHgGCyqGSIb3
# DQEJEAEEoGkEZzBlAgEBBglghkgBhv1sBwEwMTANBglghkgBZQMEAgEFAAQgDHAE
# wrb/OjfkdGEAR/N6/5LxwnpqnhSdUI5gfWTSXKECEQDkKzKdiKykh3cqy0kBK32H
# GA8yMDIzMDgyNDE0NTcyOFqgghMJMIIGwjCCBKqgAwIBAgIQBUSv85SdCDmmv9s/
# X+VhFjANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln
# aUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5
# NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTIzMDcxNDAwMDAwMFoXDTM0MTAx
# MzIzNTk1OVowSDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu
# MSAwHgYDVQQDExdEaWdpQ2VydCBUaW1lc3RhbXAgMjAyMzCCAiIwDQYJKoZIhvcN
# AQEBBQADggIPADCCAgoCggIBAKNTRYcdg45brD5UsyPgz5/X5dLnXaEOCdwvSKOX
# ejsqnGfcYhVYwamTEafNqrJq3RApih5iY2nTWJw1cb86l+uUUI8cIOrHmjsvlmbj
# aedp/lvD1isgHMGXlLSlUIHyz8sHpjBoyoNC2vx/CSSUpIIa2mq62DvKXd4ZGIX7
# ReoNYWyd/nFexAaaPPDFLnkPG2ZS48jWPl/aQ9OE9dDH9kgtXkV1lnX+3RChG4PB
# uOZSlbVH13gpOWvgeFmX40QrStWVzu8IF+qCZE3/I+PKhu60pCFkcOvV5aDaY7Mu
# 6QXuqvYk9R28mxyyt1/f8O52fTGZZUdVnUokL6wrl76f5P17cz4y7lI0+9S769Sg
# LDSb495uZBkHNwGRDxy1Uc2qTGaDiGhiu7xBG3gZbeTZD+BYQfvYsSzhUa+0rRUG
# FOpiCBPTaR58ZE2dD9/O0V6MqqtQFcmzyrzXxDtoRKOlO0L9c33u3Qr/eTQQfqZc
# ClhMAD6FaXXHg2TWdc2PEnZWpST618RrIbroHzSYLzrqawGw9/sqhux7UjipmAmh
# cbJsca8+uG+W1eEQE/5hRwqM/vC2x9XH3mwk8L9CgsqgcT2ckpMEtGlwJw1Pt7U2
# 0clfCKRwo+wK8REuZODLIivK8SgTIUlRfgZm0zu++uuRONhRB8qUt+JQofM604qD
# y0B7AgMBAAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAW
# BgNVHSUBAf8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg
# hkgBhv1sBwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8wHQYDVR0O
# BBYEFKW27xPn783QZKHVVqllMaPe1eNJMFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6
# Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEy
# NTZUaW1lU3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQGCCsGAQUF
# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKGTGh0dHA6
# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZT
# SEEyNTZUaW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIBAIEa1t6g
# qbWYF7xwjU+KPGic2CX/yyzkzepdIpLsjCICqbjPgKjZ5+PF7SaCinEvGN1Ott5s
# 1+FgnCvt7T1IjrhrunxdvcJhN2hJd6PrkKoS1yeF844ektrCQDifXcigLiV4JZ0q
# BXqEKZi2V3mP2yZWK7Dzp703DNiYdk9WuVLCtp04qYHnbUFcjGnRuSvExnvPnPp4
# 4pMadqJpddNQ5EQSviANnqlE0PjlSXcIWiHFtM+YlRpUurm8wWkZus8W8oM3NG6w
# QSbd3lqXTzON1I13fXVFoaVYJmoDRd7ZULVQjK9WvUzF4UbFKNOt50MAcN7MmJ4Z
# iQPq1JE3701S88lgIcRWR+3aEUuMMsOI5ljitts++V+wQtaP4xeR0arAVeOGv6wn
# LEHQmjNKqDbUuXKWfpd5OEhfysLcPTLfddY2Z1qJ+Panx+VPNTwAvb6cKmx5Adza
# ROY63jg7B145WPR8czFVoIARyxQMfq68/qTreWWqaNYiyjvrmoI1VygWy2nyMpqy
# 0tg6uLFGhmu6F/3Ed2wVbK6rr3M66ElGt9V/zLY4wNjsHPW2obhDLN9OTH0eaHDA
# dwrUAuBcYLso/zjlUlrWrBciI0707NMX+1Br/wd3H3GXREHJuEbTbDJ8WC9nR2Xl
# G3O2mflrLAZG70Ee8PBf4NvZrZCARK+AEEGKMIIGrjCCBJagAwIBAgIQBzY3tyRU
# fNhHrP0oZipeWzANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UE
# ChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYD
# VQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjIwMzIzMDAwMDAwWhcN
# MzcwMzIyMjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs
# IEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEy
# NTYgVGltZVN0YW1waW5nIENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC
# AgEAxoY1BkmzwT1ySVFVxyUDxPKRN6mXUaHW0oPRnkyibaCwzIP5WvYRoUQVQl+k
# iPNo+n3znIkLf50fng8zH1ATCyZzlm34V6gCff1DtITaEfFzsbPuK4CEiiIY3+va
# PcQXf6sZKz5C3GeO6lE98NZW1OcoLevTsbV15x8GZY2UKdPZ7Gnf2ZCHRgB720RB
# idx8ald68Dd5n12sy+iEZLRS8nZH92GDGd1ftFQLIWhuNyG7QKxfst5Kfc71ORJn
# 7w6lY2zkpsUdzTYNXNXmG6jBZHRAp8ByxbpOH7G1WE15/tePc5OsLDnipUjW8LAx
# E6lXKZYnLvWHpo9OdhVVJnCYJn+gGkcgQ+NDY4B7dW4nJZCYOjgRs/b2nuY7W+yB
# 3iIU2YIqx5K/oN7jPqJz+ucfWmyU8lKVEStYdEAoq3NDzt9KoRxrOMUp88qqlnNC
# aJ+2RrOdOqPVA+C/8KI8ykLcGEh/FDTP0kyr75s9/g64ZCr6dSgkQe1CvwWcZklS
# UPRR8zZJTYsg0ixXNXkrqPNFYLwjjVj33GHek/45wPmyMKVM1+mYSlg+0wOI/rOP
# 015LdhJRk8mMDDtbiiKowSYI+RQQEgN9XyO7ZONj4KbhPvbCdLI/Hgl27KtdRnXi
# YKNYCQEoAA6EVO7O6V3IXjASvUaetdN2udIOa5kM0jO0zbECAwEAAaOCAV0wggFZ
# MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLoW2W1NhS9zKXaaL3WMaiCP
# nshvMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQE
# AwIBhjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYB
# BQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0
# cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5j
# cnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp
# Z2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJ
# YIZIAYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQB9WY7Ak7ZvmKlEIgF+ZtbYIULh
# sBguEE0TzzBTzr8Y+8dQXeJLKftwig2qKWn8acHPHQfpPmDI2AvlXFvXbYf6hCAl
# NDFnzbYSlm/EUExiHQwIgqgWvalWzxVzjQEiJc6VaT9Hd/tydBTX/6tPiix6q4XN
# Q1/tYLaqT5Fmniye4Iqs5f2MvGQmh2ySvZ180HAKfO+ovHVPulr3qRCyXen/KFSJ
# 8NWKcXZl2szwcqMj+sAngkSumScbqyQeJsG33irr9p6xeZmBo1aGqwpFyd/EjaDn
# mPv7pp1yr8THwcFqcdnGE4AJxLafzYeHJLtPo0m5d2aR8XKc6UsCUqc3fpNTrDsd
# CEkPlM05et3/JWOZJyw9P2un8WbDQc1PtkCbISFA0LcTJM3cHXg65J6t5TRxktcm
# a+Q4c6umAU+9Pzt4rUyt+8SVe+0KXzM5h0F4ejjpnOHdI/0dKNPH+ejxmF/7K9h+
# 8kaddSweJywm228Vex4Ziza4k9Tm8heZWcpw8De/mADfIBZPJ/tgZxahZrrdVcA6
# KYawmKAr7ZVBtzrVFZgxtGIJDwq9gdkT/r+k0fNX2bwE+oLeMt8EifAAzV3C+dAj
# fwAL5HYCJtnwZXZCpimHCUcr5n8apIUP/JiW9lVUKx+A+sDyDivl1vupL0QVSucT
# Dh3bNzgaoSv27dZ8/DCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJ
# KoZIhvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IElu
# YzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQg
# QXNzdXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1
# OVowYjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UE
# CxMQd3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBS
# b290IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+Rd
# SjwwIjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20d
# q7J58soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7f
# gvMHhOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRA
# X7F6Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raR
# mECQecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzU
# vK4bA3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2
# mHY9WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkr
# fsCUtNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaA
# sPvoZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxf
# jT/JvNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEe
# xcCPorF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQF
# MAMBAf8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaA
# FEXroq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcB
# AQRtMGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggr
# BgEFBQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNz
# dXJlZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5k
# aWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQK
# MAgwBgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3
# v1cHvZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy
# 3iS8UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cn
# RNTnf+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3
# WlxUjG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2
# zm8jLfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDGC
# A3YwggNyAgEBMHcwYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ
# bmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJTQTQwOTYgU0hBMjU2
# IFRpbWVTdGFtcGluZyBDQQIQBUSv85SdCDmmv9s/X+VhFjANBglghkgBZQMEAgEF
# AKCB0TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkFMQ8X
# DTIzMDgyNDE0NTcyOFowKwYLKoZIhvcNAQkQAgwxHDAaMBgwFgQUZvArMsLCyQ+C
# Xc6qisnGTxmcz0AwLwYJKoZIhvcNAQkEMSIEICXoYBY9n9CHBlb2ZPmyIOzhh93Z
# zUXRoskCDmMHyq3cMDcGCyqGSIb3DQEJEAIvMSgwJjAkMCIEINL25G3tdCLM0dRA
# V2hBNm+CitpVmq4zFq9NGprUDHgoMA0GCSqGSIb3DQEBAQUABIICAFUzNs5f5wsA
# nHsLg2yauMwAyYAuQIL8+GKYnWW/AtSWnA/t+S4LbjIJaIpBzZaWTai8/I23tJJw
# W1CTYDV3hqPGG/8PEcs8RY12JQoYMRZHzHTkNvUJC9xMXfuZIxtCmoFP2xsQjLgP
# Pl45FYCo3NzWCwQ8A2SyR48lskuJ94Q7PADJHkTU7pEY0t/N6114Mo9aO+n6qSLJ
# huEu1DmWE7iarxtIKja66BQEHjdawlSbg82Fg8EfkfsAXDHLqH1pahvnWmOziFLp
# SOrFKfyUVdCoGR7k3bKkHO62AeWz/LbzN0HPkzV7xrh/PD+4rwzatBpSwzFUFgRN
# 8Zg+Kso4LgTktu3nW9rG3TkFUBM3WsP9atnUfvCGvAcDr4Qv5qSx1cyFhuTK4gRj
# FVEO93RAMu0S54vTXBQDjl/55MEgWkFinTMqbkM4DpqqMp2uBjp5sqHbns6cDOD+
# o+HyXhE9XKxu3myklhq338QMSKE8bcdQ0XogmOwqgrVkpiG3jH+2R9CJlPwsyJLM
# TYwlsno3hd4+w2SPtySgNXwZGSj2KRNJFMaePyru8QeVS33pcmXNh+CKrdW5fxk0
# C4wWetuCKJTCBDNCRzy5NjrGAkNH2F37JM1pi0n5x2esaTyLC4+gGzDfn5ki0BT4
# 8w/WVhUIJnuoyQ33wBhxukpdRjwb71K4
# SIG # End signature block

View File

@ -1,69 +0,0 @@
# This file must be used with "source bin/activate" *from bash*
# you cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
VIRTUAL_ENV="D:\dev\etc\SolorPower\crawler\venv_win"
export VIRTUAL_ENV
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/Scripts:$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1="(venv_win) ${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT="(venv_win) "
export VIRTUAL_ENV_PROMPT
fi
# This should detect bash and zsh, which have a hash command that must
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
hash -r 2> /dev/null
fi

View File

@ -1,34 +0,0 @@
@echo off
rem This file is UTF-8 encoded, so we need to update the current code page while executing it
for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do (
set _OLD_CODEPAGE=%%a
)
if defined _OLD_CODEPAGE (
"%SystemRoot%\System32\chcp.com" 65001 > nul
)
set VIRTUAL_ENV=D:\dev\etc\SolorPower\crawler\venv_win
if not defined PROMPT set PROMPT=$P$G
if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT%
if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%
set _OLD_VIRTUAL_PROMPT=%PROMPT%
set PROMPT=(venv_win) %PROMPT%
if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME%
set PYTHONHOME=
if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH%
if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH%
set PATH=%VIRTUAL_ENV%\Scripts;%PATH%
set VIRTUAL_ENV_PROMPT=(venv_win)
:END
if defined _OLD_CODEPAGE (
"%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul
set _OLD_CODEPAGE=
)

View File

@ -1,22 +0,0 @@
@echo off
if defined _OLD_VIRTUAL_PROMPT (
set "PROMPT=%_OLD_VIRTUAL_PROMPT%"
)
set _OLD_VIRTUAL_PROMPT=
if defined _OLD_VIRTUAL_PYTHONHOME (
set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%"
set _OLD_VIRTUAL_PYTHONHOME=
)
if defined _OLD_VIRTUAL_PATH (
set "PATH=%_OLD_VIRTUAL_PATH%"
)
set _OLD_VIRTUAL_PATH=
set VIRTUAL_ENV=
set VIRTUAL_ENV_PROMPT=
:END

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +0,0 @@
home = C:\Users\haneu\AppData\Local\Programs\Python\Python311
include-system-site-packages = false
version = 3.11.5
executable = C:\Users\haneu\AppData\Local\Programs\Python\Python311\python.exe
command = C:\Users\haneu\AppData\Local\Programs\Python\Python311\python.exe -m venv D:\dev\etc\SolorPower\crawler\venv_win

View File

@ -1,249 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
데이터 검증 스크립트
발전소별로 특정 날짜//연도의 실제 데이터를 조회하여 검증
"""
from datetime import datetime
from crawlers import nrems, hyundai, kremc, sun_wms, cmsolar
from config import SYSTEM_CONSTANTS
def format_hourly_data(data, date_str, plant_name):
"""시간별 데이터 포맷팅"""
print(f"\n{'='*80}")
print(f"[{plant_name}] 시간별 데이터: {date_str}")
print(f"{'='*80}")
if not data:
print(" ❌ 데이터 없음")
return
# 시간별로 그룹화
hourly_dict = {}
for record in data:
timestamp = record.get('timestamp', '')
if timestamp.startswith(date_str):
hour = timestamp.split(' ')[1][:2] if ' ' in timestamp else '00'
kwh = record.get('generation_kwh', 0)
if hour not in hourly_dict:
hourly_dict[hour] = 0
hourly_dict[hour] += kwh
if not hourly_dict:
print(" ❌ 해당 날짜 데이터 없음")
return
print(f"{len(hourly_dict)}시간 데이터")
print(f"\n {'시간':<8} {'발전량(kWh)':<15}")
print(f" {'-'*25}")
total = 0
for hour in sorted(hourly_dict.keys()):
kwh = hourly_dict[hour]
total += kwh
print(f" {hour}:00 {kwh:>10.2f}")
print(f" {'-'*25}")
print(f" {'합계':<8} {total:>10.2f}")
def format_daily_data(data, year_month, plant_name):
"""일별 데이터 포맷팅"""
print(f"\n{'='*80}")
print(f"[{plant_name}] 일별 데이터: {year_month}")
print(f"{'='*80}")
if not data:
print(" ❌ 데이터 없음")
return
# 해당 월의 데이터만 필터링
monthly_data = [d for d in data if d.get('date', '').startswith(year_month)]
if not monthly_data:
print(" ❌ 해당 월 데이터 없음")
return
print(f"{len(monthly_data)}일 데이터")
print(f"\n {'날짜':<15} {'발전량(kWh)':<15}")
print(f" {'-'*30}")
total = 0
for record in sorted(monthly_data, key=lambda x: x.get('date', '')):
date = record.get('date', '')
kwh = record.get('generation_kwh', 0)
total += kwh
print(f" {date:<15} {kwh:>10.2f}")
print(f" {'-'*30}")
print(f" {'합계':<15} {total:>10.2f}")
def format_monthly_data(data, year, plant_name):
"""월별 데이터 포맷팅"""
print(f"\n{'='*80}")
print(f"[{plant_name}] 월별 데이터: {year}")
print(f"{'='*80}")
if not data:
print(" ❌ 데이터 없음")
return
# 해당 연도의 데이터만 필터링
yearly_data = [d for d in data if d.get('month', '').startswith(year)]
if not yearly_data:
print(" ❌ 해당 연도 데이터 없음")
return
print(f"{len(yearly_data)}개월 데이터")
print(f"\n {'':<10} {'발전량(kWh)':<15}")
print(f" {'-'*25}")
total = 0
for record in sorted(yearly_data, key=lambda x: x.get('month', '')):
month = record.get('month', '')
kwh = record.get('generation_kwh', 0)
total += kwh
print(f" {month:<10} {kwh:>10.2f}")
print(f" {'-'*25}")
print(f" {'합계':<10} {total:>10.2f}")
if len(yearly_data) > 0:
print(f" {'평균':<10} {total/len(yearly_data):>10.2f}")
def verify_plant(plant_config, crawler_module):
"""개별 발전소 데이터 검증"""
plant_name = plant_config['name']
print(f"\n{'#'*80}")
print(f"# {plant_name}")
print(f"{'#'*80}")
try:
# 1. 시간별 데이터: 2025-05-10, 2024-10-20
print(f"\n[1/6] 시간별 데이터 수집 중...")
hourly_2025 = crawler_module.fetch_history_hourly(plant_config, '2025-05-10', '2025-05-10')
format_hourly_data(hourly_2025, '2025-05-10', plant_name)
hourly_2024 = crawler_module.fetch_history_hourly(plant_config, '2024-10-20', '2024-10-20')
format_hourly_data(hourly_2024, '2024-10-20', plant_name)
# 2. 일별 데이터: 2025-05, 2024-07
print(f"\n[2/6] 일별 데이터 수집 중...")
daily_2025 = crawler_module.fetch_history_daily(plant_config, '2025-05-01', '2025-05-31')
format_daily_data(daily_2025, '2025-05', plant_name)
daily_2024 = crawler_module.fetch_history_daily(plant_config, '2024-07-01', '2024-07-31')
format_daily_data(daily_2024, '2024-07', plant_name)
# 3. 월별 데이터: 2024년, 2025년
print(f"\n[3/6] 월별 데이터 수집 중...")
monthly_2025 = crawler_module.fetch_history_monthly(plant_config, '2025-01', '2025-12')
format_monthly_data(monthly_2025, '2025', plant_name)
monthly_2024 = crawler_module.fetch_history_monthly(plant_config, '2024-01', '2024-12')
format_monthly_data(monthly_2024, '2024', plant_name)
print(f"\n>>> {plant_name} 검증 완료")
except Exception as e:
print(f"\n ❌ 오류 발생: {str(e)}")
import traceback
traceback.print_exc()
def main():
"""메인 함수"""
print("\n" + "="*80)
print(">>> 발전소 데이터 검증 스크립트 <<<")
print("="*80)
print(f"검증 일시: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("\n[검증 대상]")
print(" - 시간별: 2025-05-10, 2024-10-20")
print(" - 일별: 2025년 5월, 2024년 7월")
print(" - 월별: 2025년, 2024년")
# 테스트 대상 발전소 설정
test_plants = [
# NREMS 1,2호기 (분리)
({'id': 'nrems-01', 'name': '1호기', 'type': 'nrems',
'auth': {'pscode': 'duce2023072288'},
'options': {'is_split': True, 'unit_id': 1},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
({'id': 'nrems-02', 'name': '2호기', 'type': 'nrems',
'auth': {'pscode': 'duce2023072288'},
'options': {'is_split': True, 'unit_id': 2},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# NREMS 3호기
({'id': 'nrems-03', 'name': '3호기', 'type': 'nrems',
'auth': {'pscode': 'dc2023121086'},
'options': {},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# NREMS 4호기
({'id': 'nrems-04', 'name': '4호기', 'type': 'nrems',
'auth': {'pscode': 'duce2023072269'},
'options': {},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# NREMS 9호기
({'id': 'nrems-09', 'name': '9호기', 'type': 'nrems',
'auth': {'pscode': 'a2020061008'},
'options': {},
'system': SYSTEM_CONSTANTS['nrems']}, nrems),
# KREMC 5호기
({'id': 'kremc-05', 'name': '5호기', 'type': 'kremc',
'auth': {'user_id': '서대문도서관', 'password': 'sunhope5!'},
'options': {'cid': '10013000376', 'cityProvCode': '11', 'rgnCode': '11410',
'dongCode': '1141011700', 'enso_type_code': '15001'},
'system': SYSTEM_CONSTANTS['kremc']}, kremc),
# Sun-WMS 6호기
({'id': 'sunwms-06', 'name': '6호기', 'type': 'sun_wms',
'auth': {'payload_id': 'kc0fXUW0LUm2wZa+2NQI0Q==', 'payload_pw': 'PGXjU6ib2mKYwtrh2i3fIQ=='},
'options': {},
'system': SYSTEM_CONSTANTS['sun_wms']}, sun_wms),
# Hyundai 8호기
({'id': 'hyundai-08', 'name': '8호기', 'type': 'hyundai',
'auth': {'user_id': 'epecoop', 'password': 'sunhope0419', 'site_id': 'M0494'},
'options': {},
'system': SYSTEM_CONSTANTS['hyundai']}, hyundai),
# CMSolar 10호기
({'id': 'cmsolar-10', 'name': '10호기', 'type': 'cmsolar',
'auth': {'login_id': 'smart3131', 'password': 'ehdrb!123', 'site_no': '834'},
'options': {},
'system': SYSTEM_CONSTANTS['cmsolar']}, cmsolar),
]
# 각 발전소 검증
for plant_config, crawler_module in test_plants:
try:
verify_plant(plant_config, crawler_module)
except KeyboardInterrupt:
print("\n\n⚠️ 사용자 중단")
break
except Exception as e:
print(f"\n{plant_config['name']} 검증 실패: {str(e)}")
import traceback
traceback.print_exc()
continue
print("\n" + "="*80)
print(">>> 데이터 검증 완료 <<<")
print("="*80)
if __name__ == '__main__':
main()

View File

@ -1,48 +0,0 @@
[프로젝트 개요]
- 태양광 발전소 통합 모니터링 플랫폼 구축
[프로젝트 배경 및 목표]
- 당사는 태양광 발전소 시공 및 관리를 전문으로 하는 기업입니다. 현재 각 발전소에 설치된 장비 제조사별로 모니터링 웹사이트가 달라, 발전량 데이터를 확인하기 위해 여러 사이트를 개별적으로 접속해야 하는 번거로움이 있습니다.
이에, 분산된 여러 제조사의 모니터링 사이트 데이터를 하나의 플랫폼으로 통합하여 보여주는 시스템을 구축하고자 합니다.
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

@ -1,315 +0,0 @@
# 태양광 발전소 통합 모니터링 플랫폼 — 종합 분석 보고서
> 작성일: 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

@ -1,75 +0,0 @@
# 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 오보 보호 패치 및 인프라 정리)*

View File

@ -1,80 +0,0 @@
-- ==============================================================================
-- 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

@ -1,42 +0,0 @@
# 🗄️ 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

@ -1,10 +0,0 @@
-- 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: 알림 끔)';

View File

@ -1,12 +0,0 @@
{
"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

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

View File

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

Some files were not shown because too many files have changed in this diff Show More