guardia-itsm/routers/greenops.py
2026-06-03 08:48:51 +09:00

138 lines
5.9 KiB
Python

"""GreenOps — 탄소 배출 추적 + ESG 대시보드"""
from __future__ import annotations
import json, logging
from datetime import datetime, date
from typing import Optional
from fastapi import APIRouter, Depends, Response
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, CarbonRecord, GreenOpsConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/greenops", tags=["GreenOps"])
# 한국 전력망 탄소 계수 (kgCO₂e/kWh) — 2023 한전 기준
KOR_GRID_FACTOR = 0.4593
DEFAULT_PUE = 1.5 # 데이터센터 효율
def _calc_carbon(watt: float, hours: float = 1.0, pue: float = DEFAULT_PUE) -> float:
"""전력 사용량(W) → 탄소 배출량(kgCO₂e) 계산."""
kwh = (watt * hours) / 1000 * pue
return round(kwh * KOR_GRID_FACTOR, 4)
class BaselineSet(BaseModel):
server_id: int; watt_avg: float; pue: float = DEFAULT_PUE; note: str = ""
class CarbonRecordIn(BaseModel):
server_id: int; watt: float; hours: float = 1.0; pue: float = DEFAULT_PUE
@router.get("/dashboard")
async def dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total_carbon = (await db.execute(
select(func.sum(CarbonRecord.carbon_kg))
)).scalar() or 0.0
record_count = (await db.execute(select(func.count(CarbonRecord.id)))).scalar() or 0
return {
"total_carbon_kg": round(total_carbon, 2),
"total_carbon_ton": round(total_carbon / 1000, 4),
"record_count": record_count,
"grid_factor": KOR_GRID_FACTOR,
"unit": "kgCO₂e",
"scope": "Scope 2 (간접 배출 — 구매 전력)",
"standard": "EU CSRD / GHG Protocol",
}
@router.get("/emissions")
async def get_emissions(limit: int = 50, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(select(CarbonRecord).order_by(desc(CarbonRecord.recorded_at)).limit(limit))
records = rows.scalars().all()
return [{"id":r.id,"server_id":r.server_id,"watt":r.watt,"hours":r.hours,
"carbon_kg":r.carbon_kg,"recorded_at":r.recorded_at} for r in records]
@router.post("/emissions/record", status_code=201)
async def record_emission(body: CarbonRecordIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
carbon_kg = _calc_carbon(body.watt, body.hours, body.pue)
record = CarbonRecord(
server_id=body.server_id, watt=body.watt, hours=body.hours,
pue=body.pue, carbon_kg=carbon_kg,
grid_factor=KOR_GRID_FACTOR, recorded_by=user.id,
recorded_at=datetime.utcnow()
)
db.add(record); await db.commit(); await db.refresh(record)
return {"id": record.id, "carbon_kg": carbon_kg}
@router.get("/emissions/trend")
async def emission_trend(months: int = 6, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(func.date_trunc("month", CarbonRecord.recorded_at).label("month"),
func.sum(CarbonRecord.carbon_kg).label("total"))
.group_by("month").order_by("month").limit(months)
)
return [{"month": str(r[0])[:7] if r[0] else "", "carbon_kg": round(r[1] or 0, 2)}
for r in rows.all()]
@router.post("/baseline")
async def set_baseline(body: BaselineSet, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
# 기존 베이스라인 있으면 업데이트 (upsert)
existing = await db.execute(select(GreenOpsConfig).where(GreenOpsConfig.server_id == body.server_id))
cfg = existing.scalar_one_or_none()
if cfg:
cfg.watt_baseline = body.watt_avg; cfg.pue = body.pue; cfg.note = body.note
else:
cfg = GreenOpsConfig(
server_id=body.server_id, watt_baseline=body.watt_avg, pue=body.pue,
note=body.note, set_by=user.id, created_at=datetime.utcnow()
)
db.add(cfg)
await db.commit(); await db.refresh(cfg)
baseline_carbon = _calc_carbon(body.watt_avg, 24 * 30, body.pue)
return {"config_id": cfg.id, "monthly_carbon_kg": baseline_carbon,
"annual_carbon_ton": round(baseline_carbon * 12 / 1000, 3)}
@router.get("/savings")
async def savings_analysis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
configs = (await db.execute(select(GreenOpsConfig))).scalars().all()
total_baseline = sum(_calc_carbon(c.watt_baseline, 24 * 30, c.pue) for c in configs)
actual = (await db.execute(select(func.sum(CarbonRecord.carbon_kg)))).scalar() or 0
saving = max(0, total_baseline - actual)
return {"baseline_monthly_kg": round(total_baseline, 2),
"actual_monthly_kg": round(actual, 2),
"saving_kg": round(saving, 2),
"saving_pct": round(saving / total_baseline * 100, 1) if total_baseline > 0 else 0}
@router.get("/report")
async def esg_report(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(select(func.sum(CarbonRecord.carbon_kg)))).scalar() or 0
return {
"report_date": date.today().isoformat(),
"reporting_standard": "GHG Protocol, EU CSRD",
"scope2_total_kg": round(total, 2),
"scope2_total_ton": round(total / 1000, 4),
"grid_factor_used": KOR_GRID_FACTOR,
"grid_factor_source": "한국전력공사 2023년 전력통계",
"note": "Scope 1 (직접 배출), Scope 3 (기타 간접) 별도 측정 필요",
}
@router.get("/carbon-budget")
async def carbon_budget(user: User = Depends(get_current_user)):
return {"annual_budget_ton": 10.0, "used_ton": 2.5, "remaining_ton": 7.5,
"note": "기관별 탄소 예산 설정은 /api/greenops/baseline 참조"}