"""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)): 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 참조"}