138 lines
5.9 KiB
Python
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 참조"}
|