BigScreen fixes: - Fix NaN in 今日用电 (normalize energy_today structure) - Fix 总设备=0 (compute from online+offline) - Fix energy flow zeros (map total_load→total_power) - Fix 今日发电=0 (extract from nested energy_today) Backend fixes (synced from ems-core): - Carbon overview fallback from energy_data × emission_factors - Energy history: datetime parsing (was 500) - Dashboard generation: station-level dedup (93K→14.8K kWh) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
484 lines
17 KiB
Python
484 lines
17 KiB
Python
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Optional
|
||
from fastapi import APIRouter, Depends, Query, HTTPException, Body
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, and_, text
|
||
from app.core.database import get_db
|
||
from app.core.config import get_settings
|
||
from app.core.deps import get_current_user
|
||
from app.models.carbon import (
|
||
CarbonEmission, EmissionFactor, CarbonTarget, CarbonReduction,
|
||
GreenCertificate, CarbonReport, CarbonBenchmark,
|
||
)
|
||
from app.models.user import User
|
||
from app.services import carbon_asset
|
||
|
||
router = APIRouter(prefix="/carbon", tags=["碳排放管理"])
|
||
|
||
|
||
# --------------- Pydantic Schemas ---------------
|
||
|
||
class TargetCreate(BaseModel):
|
||
year: int
|
||
month: Optional[int] = None
|
||
target_emission_tons: float
|
||
|
||
class TargetUpdate(BaseModel):
|
||
target_emission_tons: Optional[float] = None
|
||
status: Optional[str] = None
|
||
|
||
class CertificateCreate(BaseModel):
|
||
certificate_type: str
|
||
certificate_number: str
|
||
issue_date: date
|
||
expiry_date: Optional[date] = None
|
||
energy_mwh: float
|
||
price_yuan: float = 0
|
||
status: str = "active"
|
||
source_device_id: Optional[int] = None
|
||
notes: Optional[str] = None
|
||
|
||
class CertificateUpdate(BaseModel):
|
||
status: Optional[str] = None
|
||
price_yuan: Optional[float] = None
|
||
notes: Optional[str] = None
|
||
|
||
class ReportGenerate(BaseModel):
|
||
report_type: str = Field(..., pattern="^(monthly|quarterly|annual)$")
|
||
period_start: date
|
||
period_end: date
|
||
|
||
|
||
@router.get("/overview")
|
||
async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""碳排放总览 - 优先从carbon_emissions表读取,为空时从energy_data实时计算"""
|
||
from app.models.energy import EnergyData
|
||
from app.models.device import Device
|
||
|
||
now = datetime.now(timezone.utc)
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
year_start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
async def sum_carbon(start, end):
|
||
r = await db.execute(
|
||
select(func.sum(CarbonEmission.emission), func.sum(CarbonEmission.reduction))
|
||
.where(and_(CarbonEmission.date >= start, CarbonEmission.date < end))
|
||
)
|
||
row = r.first()
|
||
return {"emission": row[0] or 0, "reduction": row[1] or 0}
|
||
|
||
today = await sum_carbon(today_start, now)
|
||
month = await sum_carbon(month_start, now)
|
||
year = await sum_carbon(year_start, now)
|
||
|
||
# Fallback: if carbon_emissions is empty, compute reduction from PV generation
|
||
has_carbon_data = (today["emission"] + today["reduction"] +
|
||
month["emission"] + month["reduction"] +
|
||
year["emission"] + year["reduction"]) > 0
|
||
|
||
if not has_carbon_data:
|
||
# Get grid emission factor (华北电网 0.582 kgCO2/kWh)
|
||
factor_q = await db.execute(
|
||
select(EmissionFactor.factor).where(
|
||
EmissionFactor.energy_type == "electricity"
|
||
).order_by(EmissionFactor.id).limit(1)
|
||
)
|
||
grid_factor = factor_q.scalar() or 0.582 # default fallback
|
||
|
||
# Compute PV generation from energy_data using latest daily_energy per station
|
||
# Device names like AP1xx belong to station 1, AP2xx to station 2
|
||
# To avoid double-counting station-level data written to multiple devices,
|
||
# we group by station prefix (first 3 chars of device name) and take MAX
|
||
async def compute_pv_reduction(start, end):
|
||
q = await db.execute(
|
||
select(
|
||
func.substring(Device.name, text("1"), text("3")).label("station"),
|
||
func.max(EnergyData.value).label("max_energy"),
|
||
).select_from(EnergyData).join(
|
||
Device, EnergyData.device_id == Device.id
|
||
).where(
|
||
and_(
|
||
EnergyData.timestamp >= start,
|
||
EnergyData.timestamp < end,
|
||
EnergyData.data_type == "daily_energy",
|
||
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||
)
|
||
).group_by(text("station"))
|
||
)
|
||
total_kwh = sum(row[1] or 0 for row in q.all())
|
||
# Carbon reduction (kg CO2) = generation (kWh) * grid emission factor
|
||
return round(total_kwh * grid_factor / 1000, 4) # convert to tons
|
||
|
||
today_reduction = await compute_pv_reduction(today_start, now)
|
||
month_reduction = await compute_pv_reduction(month_start, now)
|
||
year_reduction = await compute_pv_reduction(year_start, now)
|
||
|
||
today = {"emission": 0, "reduction": today_reduction}
|
||
month = {"emission": 0, "reduction": month_reduction}
|
||
year = {"emission": 0, "reduction": year_reduction}
|
||
|
||
# 各scope分布
|
||
scope_q = await db.execute(
|
||
select(CarbonEmission.scope, func.sum(CarbonEmission.emission))
|
||
.where(CarbonEmission.date >= year_start)
|
||
.group_by(CarbonEmission.scope)
|
||
)
|
||
by_scope = {row[0]: round(row[1], 2) for row in scope_q.all()}
|
||
|
||
return {
|
||
"today": {"emission": round(today["emission"], 2), "reduction": round(today["reduction"], 2)},
|
||
"month": {"emission": round(month["emission"], 2), "reduction": round(month["reduction"], 2)},
|
||
"year": {"emission": round(year["emission"], 2), "reduction": round(year["reduction"], 2)},
|
||
"by_scope": by_scope,
|
||
}
|
||
|
||
|
||
@router.get("/trend")
|
||
async def carbon_trend(
|
||
days: int = Query(30, ge=1, le=365),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""碳排放趋势"""
|
||
start = datetime.now(timezone.utc) - timedelta(days=days)
|
||
settings = get_settings()
|
||
if settings.is_sqlite:
|
||
day_expr = func.strftime('%Y-%m-%d', CarbonEmission.date).label('day')
|
||
else:
|
||
day_expr = func.date_trunc('day', CarbonEmission.date).label('day')
|
||
|
||
result = await db.execute(
|
||
select(
|
||
day_expr,
|
||
func.sum(CarbonEmission.emission),
|
||
func.sum(CarbonEmission.reduction),
|
||
).where(CarbonEmission.date >= start)
|
||
.group_by(text('day')).order_by(text('day'))
|
||
)
|
||
return [{"date": str(r[0]), "emission": round(r[1], 2), "reduction": round(r[2], 2)} for r in result.all()]
|
||
|
||
|
||
@router.get("/factors")
|
||
async def list_factors(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||
result = await db.execute(select(EmissionFactor).order_by(EmissionFactor.id))
|
||
return [{
|
||
"id": f.id, "name": f.name, "energy_type": f.energy_type, "factor": f.factor,
|
||
"unit": f.unit, "scope": f.scope, "region": f.region, "source": f.source,
|
||
} for f in result.scalars().all()]
|
||
|
||
|
||
# =============== Carbon Dashboard ===============
|
||
|
||
@router.get("/dashboard")
|
||
async def carbon_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""综合碳资产仪表盘"""
|
||
return await carbon_asset.get_carbon_dashboard(db)
|
||
|
||
|
||
# =============== Carbon Targets ===============
|
||
|
||
@router.get("/targets")
|
||
async def list_targets(
|
||
year: int = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""碳减排目标列表"""
|
||
q = select(CarbonTarget).order_by(CarbonTarget.year.desc(), CarbonTarget.month)
|
||
if year:
|
||
q = q.where(CarbonTarget.year == year)
|
||
result = await db.execute(q)
|
||
targets = result.scalars().all()
|
||
return [{
|
||
"id": t.id, "year": t.year, "month": t.month,
|
||
"target_emission_tons": t.target_emission_tons,
|
||
"actual_emission_tons": t.actual_emission_tons,
|
||
"status": t.status,
|
||
} for t in targets]
|
||
|
||
|
||
@router.post("/targets")
|
||
async def create_target(
|
||
data: TargetCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""创建碳减排目标"""
|
||
target = CarbonTarget(
|
||
year=data.year,
|
||
month=data.month,
|
||
target_emission_tons=data.target_emission_tons,
|
||
)
|
||
db.add(target)
|
||
await db.flush()
|
||
return {"id": target.id, "message": "目标创建成功"}
|
||
|
||
|
||
@router.put("/targets/{target_id}")
|
||
async def update_target(
|
||
target_id: int,
|
||
data: TargetUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""更新碳减排目标"""
|
||
result = await db.execute(select(CarbonTarget).where(CarbonTarget.id == target_id))
|
||
target = result.scalar_one_or_none()
|
||
if not target:
|
||
raise HTTPException(404, "目标不存在")
|
||
if data.target_emission_tons is not None:
|
||
target.target_emission_tons = data.target_emission_tons
|
||
if data.status is not None:
|
||
target.status = data.status
|
||
return {"message": "更新成功"}
|
||
|
||
|
||
@router.get("/targets/progress")
|
||
async def target_progress(
|
||
year: int = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""碳目标进度"""
|
||
if year is None:
|
||
year = datetime.now(timezone.utc).year
|
||
return await carbon_asset.get_target_progress(db, year)
|
||
|
||
|
||
# =============== Carbon Reductions ===============
|
||
|
||
@router.get("/reductions")
|
||
async def list_reductions(
|
||
start: date = Query(None),
|
||
end: date = Query(None),
|
||
source_type: str = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""碳减排活动列表"""
|
||
q = select(CarbonReduction).order_by(CarbonReduction.date.desc())
|
||
if start:
|
||
q = q.where(CarbonReduction.date >= start)
|
||
if end:
|
||
q = q.where(CarbonReduction.date <= end)
|
||
if source_type:
|
||
q = q.where(CarbonReduction.source_type == source_type)
|
||
result = await db.execute(q.limit(500))
|
||
items = result.scalars().all()
|
||
return [{
|
||
"id": r.id, "source_type": r.source_type, "date": str(r.date),
|
||
"reduction_tons": r.reduction_tons, "equivalent_trees": r.equivalent_trees,
|
||
"methodology": r.methodology, "verified": r.verified,
|
||
} for r in items]
|
||
|
||
|
||
@router.get("/reductions/summary")
|
||
async def reduction_summary(
|
||
start: date = Query(None),
|
||
end: date = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""减排汇总(按来源类型)"""
|
||
if not start:
|
||
start = date(datetime.now(timezone.utc).year, 1, 1)
|
||
if not end:
|
||
end = datetime.now(timezone.utc).date()
|
||
return await carbon_asset.get_reduction_summary(db, start, end)
|
||
|
||
|
||
@router.post("/reductions/calculate")
|
||
async def calculate_reductions(
|
||
start: date = Query(None),
|
||
end: date = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""触发减排量计算"""
|
||
if not start:
|
||
start = date(datetime.now(timezone.utc).year, 1, 1)
|
||
if not end:
|
||
end = datetime.now(timezone.utc).date()
|
||
return await carbon_asset.trigger_reduction_calculation(db, start, end)
|
||
|
||
|
||
# =============== Green Certificates ===============
|
||
|
||
@router.get("/certificates")
|
||
async def list_certificates(
|
||
status: str = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""绿证列表"""
|
||
q = select(GreenCertificate).order_by(GreenCertificate.issue_date.desc())
|
||
if status:
|
||
q = q.where(GreenCertificate.status == status)
|
||
result = await db.execute(q)
|
||
certs = result.scalars().all()
|
||
return [{
|
||
"id": c.id, "certificate_type": c.certificate_type,
|
||
"certificate_number": c.certificate_number,
|
||
"issue_date": str(c.issue_date), "expiry_date": str(c.expiry_date) if c.expiry_date else None,
|
||
"energy_mwh": c.energy_mwh, "price_yuan": c.price_yuan,
|
||
"status": c.status, "notes": c.notes,
|
||
} for c in certs]
|
||
|
||
|
||
@router.post("/certificates")
|
||
async def create_certificate(
|
||
data: CertificateCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""登记绿证"""
|
||
cert = GreenCertificate(**data.model_dump())
|
||
db.add(cert)
|
||
await db.flush()
|
||
return {"id": cert.id, "message": "绿证登记成功"}
|
||
|
||
|
||
@router.put("/certificates/{cert_id}")
|
||
async def update_certificate(
|
||
cert_id: int,
|
||
data: CertificateUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""更新绿证"""
|
||
result = await db.execute(select(GreenCertificate).where(GreenCertificate.id == cert_id))
|
||
cert = result.scalar_one_or_none()
|
||
if not cert:
|
||
raise HTTPException(404, "绿证不存在")
|
||
for k, v in data.model_dump(exclude_unset=True).items():
|
||
setattr(cert, k, v)
|
||
return {"message": "更新成功"}
|
||
|
||
|
||
@router.get("/certificates/value")
|
||
async def certificate_portfolio_value(
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""绿证组合价值"""
|
||
return await carbon_asset.get_certificate_portfolio_value(db)
|
||
|
||
|
||
# =============== Carbon Reports ===============
|
||
|
||
@router.get("/reports")
|
||
async def list_reports(
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""报告列表"""
|
||
result = await db.execute(
|
||
select(CarbonReport).order_by(CarbonReport.generated_at.desc()).limit(50)
|
||
)
|
||
reports = result.scalars().all()
|
||
return [{
|
||
"id": r.id, "report_type": r.report_type,
|
||
"period_start": str(r.period_start), "period_end": str(r.period_end),
|
||
"generated_at": str(r.generated_at),
|
||
"total_tons": r.total_tons, "reduction_tons": r.reduction_tons,
|
||
"net_tons": r.net_tons,
|
||
} for r in reports]
|
||
|
||
|
||
@router.post("/reports/generate")
|
||
async def generate_report(
|
||
data: ReportGenerate,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""生成碳报告"""
|
||
report = await carbon_asset.generate_carbon_report(
|
||
db, data.report_type, data.period_start, data.period_end,
|
||
)
|
||
await db.flush()
|
||
return {
|
||
"id": report.id,
|
||
"total_tons": report.total_tons,
|
||
"reduction_tons": report.reduction_tons,
|
||
"net_tons": report.net_tons,
|
||
"message": "报告生成成功",
|
||
}
|
||
|
||
|
||
@router.get("/reports/{report_id}")
|
||
async def get_report(
|
||
report_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""报告详情"""
|
||
result = await db.execute(select(CarbonReport).where(CarbonReport.id == report_id))
|
||
r = result.scalar_one_or_none()
|
||
if not r:
|
||
raise HTTPException(404, "报告不存在")
|
||
return {
|
||
"id": r.id, "report_type": r.report_type,
|
||
"period_start": str(r.period_start), "period_end": str(r.period_end),
|
||
"generated_at": str(r.generated_at),
|
||
"scope1_tons": r.scope1_tons, "scope2_tons": r.scope2_tons,
|
||
"scope3_tons": r.scope3_tons,
|
||
"total_tons": r.total_tons, "reduction_tons": r.reduction_tons,
|
||
"net_tons": r.net_tons, "report_data": r.report_data,
|
||
}
|
||
|
||
|
||
@router.get("/reports/{report_id}/download")
|
||
async def download_report(
|
||
report_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""下载报告数据"""
|
||
result = await db.execute(select(CarbonReport).where(CarbonReport.id == report_id))
|
||
r = result.scalar_one_or_none()
|
||
if not r:
|
||
raise HTTPException(404, "报告不存在")
|
||
return {
|
||
"report_type": r.report_type,
|
||
"period": f"{r.period_start} ~ {r.period_end}",
|
||
"scope1_tons": r.scope1_tons, "scope2_tons": r.scope2_tons,
|
||
"total_tons": r.total_tons, "reduction_tons": r.reduction_tons,
|
||
"net_tons": r.net_tons,
|
||
"detail": r.report_data,
|
||
}
|
||
|
||
|
||
# =============== Carbon Benchmarks ===============
|
||
|
||
@router.get("/benchmarks")
|
||
async def list_benchmarks(
|
||
year: int = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""行业基准列表"""
|
||
q = select(CarbonBenchmark).order_by(CarbonBenchmark.year.desc())
|
||
if year:
|
||
q = q.where(CarbonBenchmark.year == year)
|
||
result = await db.execute(q)
|
||
items = result.scalars().all()
|
||
return [{
|
||
"id": b.id, "industry": b.industry, "metric_name": b.metric_name,
|
||
"benchmark_value": b.benchmark_value, "unit": b.unit,
|
||
"year": b.year, "source": b.source, "notes": b.notes,
|
||
} for b in items]
|
||
|
||
|
||
@router.get("/benchmarks/comparison")
|
||
async def benchmark_comparison(
|
||
year: int = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""行业对标比较"""
|
||
if year is None:
|
||
year = datetime.now(timezone.utc).year
|
||
return await carbon_asset.compare_with_benchmarks(db, year)
|