solorpower/crawler/database.py
haneulai a716dbef96
Some checks are pending
CI / Crawler (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Crawler (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / API (Python 3.11) (push) Waiting to run
CI / Database migration (push) Waiting to run
CI / App web build (Node 20) (push) Waiting to run
feat: harden solar monitoring through stage 7
2026-08-07 14:07:22 +09:00

327 lines
12 KiB
Python

# ==========================================
# database.py - Supabase 연동
# ==========================================
import os
from datetime import datetime
from time_utils import ensure_kst, now_kst
# 환경 변수에서 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 upsert_daily_stats(client, records, source, allow_decrease=False):
"""Store daily stats through the database's single conflict policy.
Automated sources preserve the highest daily generation. Only an explicit
correction path (currently Excel upload) may pass ``allow_decrease=True``.
The database function also derives generation_hours and refreshes the
affected monthly aggregate.
"""
if not records:
return []
payload = []
for record in records:
normalized = {
"plant_id": record["plant_id"],
"date": record["date"],
"total_generation": float(record["total_generation"]),
"peak_kw": float(record.get("peak_kw") or 0),
}
payload.append(normalized)
result = client.rpc(
"upsert_daily_stats",
{
"p_records": payload,
"p_source": source,
"p_allow_decrease": allow_decrease,
},
).execute()
return result.data or []
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) 타임스탬프 생성
kst_now = 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 갱신은 DB 함수의 공통 최댓값 정책을 사용한다.
# 오류/0/야간 값은 호출 전에 제외하고, 충돌과 월간 재집계는 DB가 처리한다.
kst_now_dt = 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 = []
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
daily_records.append({
"plant_id": plant_id,
"date": kst_date_str,
"total_generation": today_val,
"peak_kw": float(item.get('kw', 0)),
})
if daily_records:
try:
upsert_daily_stats(
client,
daily_records,
source="realtime",
allow_decrease=False,
)
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
current_kst = 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)
dt_ts = ensure_kst(dt_ts)
if dt_ts > current_kst:
continue # Skip future data
except ValueError:
continue
# 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:
generation_kwh = float(item['generation_kwh'])
if generation_kwh < 0:
raise ValueError(
f"daily generation_kwh must be non-negative: "
f"{item.get('plant_id')} {item.get('date')}"
)
records.append({
'plant_id': item['plant_id'],
'date': item['date'],
'total_generation': generation_kwh,
'peak_kw': 0.0,
})
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': now_kst().isoformat(),
'source': 'history_monthly',
})
if not records:
return False
# upsert 사용
if data_type == 'hourly':
client.table(table_name).insert(records).execute()
elif data_type == 'daily':
# DB 함수가 최댓값 보호와 월간 통계 갱신을 한 트랜잭션으로 처리한다.
upsert_daily_stats(
client,
records,
source="history",
allow_decrease=False,
)
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