Merge commit '026c837b919ab4380e8a6e6c052364bbf9bbe8a3' as 'core'
This commit is contained in:
462
core/backend/app/services/carbon_asset.py
Normal file
462
core/backend/app/services/carbon_asset.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""Carbon Asset Management Service.
|
||||
|
||||
Provides carbon accounting, CCER/green certificate management,
|
||||
report generation, target tracking, and benchmark comparison.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, and_, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.carbon import (
|
||||
CarbonEmission, EmissionFactor, CarbonTarget, CarbonReduction,
|
||||
GreenCertificate, CarbonReport, CarbonBenchmark,
|
||||
)
|
||||
from app.models.energy import EnergyData
|
||||
|
||||
logger = logging.getLogger("carbon_asset")
|
||||
|
||||
# China national grid emission factor 2023 (tCO2/MWh)
|
||||
GRID_EMISSION_FACTOR = 0.5810
|
||||
# Average CO2 absorption per tree per year (tons)
|
||||
TREE_ABSORPTION = 0.02
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Carbon Accounting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def calculate_scope2_emission(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> float:
|
||||
"""Calculate Scope 2 emission from grid electricity (tons CO2)."""
|
||||
result = await db.execute(
|
||||
select(func.sum(CarbonEmission.emission))
|
||||
.where(and_(
|
||||
CarbonEmission.scope == 2,
|
||||
func.date(CarbonEmission.date) >= start,
|
||||
func.date(CarbonEmission.date) <= end,
|
||||
))
|
||||
)
|
||||
val = result.scalar() or 0
|
||||
return round(val / 1000, 4) # kg -> tons
|
||||
|
||||
|
||||
async def calculate_scope1_emission(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> float:
|
||||
"""Calculate Scope 1 direct emissions (natural gas etc.) in tons CO2."""
|
||||
result = await db.execute(
|
||||
select(func.sum(CarbonEmission.emission))
|
||||
.where(and_(
|
||||
CarbonEmission.scope == 1,
|
||||
func.date(CarbonEmission.date) >= start,
|
||||
func.date(CarbonEmission.date) <= end,
|
||||
))
|
||||
)
|
||||
val = result.scalar() or 0
|
||||
return round(val / 1000, 4)
|
||||
|
||||
|
||||
async def calculate_total_reduction(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> float:
|
||||
"""Total carbon reduction in tons."""
|
||||
result = await db.execute(
|
||||
select(func.sum(CarbonEmission.reduction))
|
||||
.where(and_(
|
||||
func.date(CarbonEmission.date) >= start,
|
||||
func.date(CarbonEmission.date) <= end,
|
||||
))
|
||||
)
|
||||
val = result.scalar() or 0
|
||||
return round(val / 1000, 4)
|
||||
|
||||
|
||||
async def calculate_pv_reduction(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> float:
|
||||
"""Carbon reduction from PV self-consumption (tons).
|
||||
|
||||
Formula: reduction = pv_generation_mwh * GRID_EMISSION_FACTOR
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(func.sum(CarbonEmission.reduction))
|
||||
.where(and_(
|
||||
CarbonEmission.category == "pv_generation",
|
||||
func.date(CarbonEmission.date) >= start,
|
||||
func.date(CarbonEmission.date) <= end,
|
||||
))
|
||||
)
|
||||
val = result.scalar() or 0
|
||||
return round(val / 1000, 4)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reduction tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_reduction_summary(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> list[dict]:
|
||||
"""Reduction summary grouped by source type."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
CarbonReduction.source_type,
|
||||
func.sum(CarbonReduction.reduction_tons),
|
||||
func.sum(CarbonReduction.equivalent_trees),
|
||||
func.count(CarbonReduction.id),
|
||||
)
|
||||
.where(and_(
|
||||
CarbonReduction.date >= start,
|
||||
CarbonReduction.date <= end,
|
||||
))
|
||||
.group_by(CarbonReduction.source_type)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"source_type": row[0],
|
||||
"reduction_tons": round(row[1] or 0, 4),
|
||||
"equivalent_trees": round(row[2] or 0, 1),
|
||||
"count": row[3],
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
|
||||
async def trigger_reduction_calculation(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> dict:
|
||||
"""Compute reduction activities from energy data and persist them.
|
||||
|
||||
Calculates PV generation reduction and heat pump COP savings.
|
||||
"""
|
||||
# PV reduction from carbon_emissions records
|
||||
pv_tons = await calculate_pv_reduction(db, start, end)
|
||||
total_reduction = await calculate_total_reduction(db, start, end)
|
||||
heat_pump_tons = max(0, total_reduction - pv_tons)
|
||||
|
||||
records_created = 0
|
||||
for source, tons in [("pv_generation", pv_tons), ("heat_pump_cop", heat_pump_tons)]:
|
||||
if tons > 0:
|
||||
existing = await db.execute(
|
||||
select(CarbonReduction).where(and_(
|
||||
CarbonReduction.source_type == source,
|
||||
CarbonReduction.date == end,
|
||||
))
|
||||
)
|
||||
if existing.scalar_one_or_none() is None:
|
||||
db.add(CarbonReduction(
|
||||
source_type=source,
|
||||
date=end,
|
||||
reduction_tons=tons,
|
||||
equivalent_trees=round(tons / TREE_ABSORPTION, 1),
|
||||
methodology=f"Grid factor {GRID_EMISSION_FACTOR} tCO2/MWh",
|
||||
verified=False,
|
||||
))
|
||||
records_created += 1
|
||||
|
||||
return {
|
||||
"period": f"{start} ~ {end}",
|
||||
"pv_reduction_tons": pv_tons,
|
||||
"heat_pump_reduction_tons": heat_pump_tons,
|
||||
"total_reduction_tons": total_reduction,
|
||||
"records_created": records_created,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CCER / Green Certificate Management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def calculate_ccer_eligible(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> dict:
|
||||
"""Calculate eligible CCER reduction from PV generation."""
|
||||
pv_tons = await calculate_pv_reduction(db, start, end)
|
||||
return {
|
||||
"eligible_ccer_tons": pv_tons,
|
||||
"grid_emission_factor": GRID_EMISSION_FACTOR,
|
||||
"period": f"{start} ~ {end}",
|
||||
}
|
||||
|
||||
|
||||
async def get_certificate_portfolio_value(db: AsyncSession) -> dict:
|
||||
"""Total value of active green certificates."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
GreenCertificate.status,
|
||||
func.count(GreenCertificate.id),
|
||||
func.sum(GreenCertificate.energy_mwh),
|
||||
func.sum(GreenCertificate.price_yuan),
|
||||
).group_by(GreenCertificate.status)
|
||||
)
|
||||
rows = result.all()
|
||||
total_value = 0.0
|
||||
total_mwh = 0.0
|
||||
by_status = {}
|
||||
for row in rows:
|
||||
status, cnt, mwh, value = row
|
||||
by_status[status] = {
|
||||
"count": cnt,
|
||||
"energy_mwh": round(mwh or 0, 2),
|
||||
"value_yuan": round(value or 0, 2),
|
||||
}
|
||||
total_value += value or 0
|
||||
total_mwh += mwh or 0
|
||||
|
||||
return {
|
||||
"total_certificates": sum(v["count"] for v in by_status.values()),
|
||||
"total_energy_mwh": round(total_mwh, 2),
|
||||
"total_value_yuan": round(total_value, 2),
|
||||
"by_status": by_status,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Carbon Report Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def generate_carbon_report(
|
||||
db: AsyncSession,
|
||||
report_type: str,
|
||||
period_start: date,
|
||||
period_end: date,
|
||||
) -> CarbonReport:
|
||||
"""Generate a carbon footprint report for the given period."""
|
||||
scope1 = await calculate_scope1_emission(db, period_start, period_end)
|
||||
scope2 = await calculate_scope2_emission(db, period_start, period_end)
|
||||
reduction = await calculate_total_reduction(db, period_start, period_end)
|
||||
total = scope1 + scope2
|
||||
net = total - reduction
|
||||
|
||||
# Reduction breakdown
|
||||
reduction_summary = await get_reduction_summary(db, period_start, period_end)
|
||||
|
||||
# Monthly breakdown
|
||||
monthly = await _monthly_breakdown(db, period_start, period_end)
|
||||
|
||||
report_data = {
|
||||
"scope_breakdown": {"scope1": scope1, "scope2": scope2},
|
||||
"reduction_summary": reduction_summary,
|
||||
"monthly_breakdown": monthly,
|
||||
"grid_emission_factor": GRID_EMISSION_FACTOR,
|
||||
"net_emission_tons": round(net, 4),
|
||||
"green_rate": round((reduction / total * 100) if total > 0 else 0, 1),
|
||||
}
|
||||
|
||||
report = CarbonReport(
|
||||
report_type=report_type,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
scope1_tons=scope1,
|
||||
scope2_tons=scope2,
|
||||
total_tons=round(total, 4),
|
||||
reduction_tons=round(reduction, 4),
|
||||
net_tons=round(net, 4),
|
||||
report_data=report_data,
|
||||
)
|
||||
db.add(report)
|
||||
return report
|
||||
|
||||
|
||||
async def _monthly_breakdown(
|
||||
db: AsyncSession, start: date, end: date,
|
||||
) -> list[dict]:
|
||||
"""Monthly emission and reduction totals for the period."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
if settings.is_sqlite:
|
||||
month_expr = func.strftime('%Y-%m', CarbonEmission.date).label('month')
|
||||
else:
|
||||
month_expr = func.to_char(
|
||||
func.date_trunc('month', CarbonEmission.date), 'YYYY-MM'
|
||||
).label('month')
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
month_expr,
|
||||
func.sum(CarbonEmission.emission),
|
||||
func.sum(CarbonEmission.reduction),
|
||||
)
|
||||
.where(and_(
|
||||
func.date(CarbonEmission.date) >= start,
|
||||
func.date(CarbonEmission.date) <= end,
|
||||
))
|
||||
.group_by('month')
|
||||
.order_by('month')
|
||||
)
|
||||
return [
|
||||
{
|
||||
"month": row[0],
|
||||
"emission_kg": round(row[1] or 0, 2),
|
||||
"reduction_kg": round(row[2] or 0, 2),
|
||||
"emission_tons": round((row[1] or 0) / 1000, 4),
|
||||
"reduction_tons": round((row[2] or 0) / 1000, 4),
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Carbon Target Tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_target_progress(db: AsyncSession, year: int) -> dict:
|
||||
"""Calculate progress against annual and monthly targets."""
|
||||
# Annual target
|
||||
annual_q = await db.execute(
|
||||
select(CarbonTarget).where(and_(
|
||||
CarbonTarget.year == year,
|
||||
CarbonTarget.month.is_(None),
|
||||
))
|
||||
)
|
||||
annual = annual_q.scalar_one_or_none()
|
||||
|
||||
# All monthly targets for this year
|
||||
monthly_q = await db.execute(
|
||||
select(CarbonTarget).where(and_(
|
||||
CarbonTarget.year == year,
|
||||
CarbonTarget.month.isnot(None),
|
||||
)).order_by(CarbonTarget.month)
|
||||
)
|
||||
monthlies = monthly_q.scalars().all()
|
||||
|
||||
# Current year actuals
|
||||
year_start = date(year, 1, 1)
|
||||
year_end = date(year, 12, 31)
|
||||
scope1 = await calculate_scope1_emission(db, year_start, year_end)
|
||||
scope2 = await calculate_scope2_emission(db, year_start, year_end)
|
||||
reduction = await calculate_total_reduction(db, year_start, year_end)
|
||||
total_emission = scope1 + scope2
|
||||
net = total_emission - reduction
|
||||
|
||||
annual_data = None
|
||||
if annual:
|
||||
progress = (net / annual.target_emission_tons * 100) if annual.target_emission_tons > 0 else 0
|
||||
status = "on_track" if progress <= 80 else ("warning" if progress <= 100 else "exceeded")
|
||||
annual_data = {
|
||||
"id": annual.id,
|
||||
"target_tons": annual.target_emission_tons,
|
||||
"actual_tons": round(net, 4),
|
||||
"progress_pct": round(progress, 1),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
monthly_data = []
|
||||
for m in monthlies:
|
||||
m_start = date(year, m.month, 1)
|
||||
if m.month == 12:
|
||||
m_end = date(year, 12, 31)
|
||||
else:
|
||||
m_end = date(year, m.month + 1, 1)
|
||||
s1 = await calculate_scope1_emission(db, m_start, m_end)
|
||||
s2 = await calculate_scope2_emission(db, m_start, m_end)
|
||||
red = await calculate_total_reduction(db, m_start, m_end)
|
||||
m_net = s1 + s2 - red
|
||||
pct = (m_net / m.target_emission_tons * 100) if m.target_emission_tons > 0 else 0
|
||||
monthly_data.append({
|
||||
"id": m.id,
|
||||
"month": m.month,
|
||||
"target_tons": m.target_emission_tons,
|
||||
"actual_tons": round(m_net, 4),
|
||||
"progress_pct": round(pct, 1),
|
||||
"status": "on_track" if pct <= 80 else ("warning" if pct <= 100 else "exceeded"),
|
||||
})
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"total_emission_tons": round(total_emission, 4),
|
||||
"total_reduction_tons": round(reduction, 4),
|
||||
"net_emission_tons": round(net, 4),
|
||||
"annual_target": annual_data,
|
||||
"monthly_targets": monthly_data,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark Comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def compare_with_benchmarks(
|
||||
db: AsyncSession, year: int,
|
||||
) -> dict:
|
||||
"""Compare actual emissions with industry benchmarks."""
|
||||
benchmarks_q = await db.execute(
|
||||
select(CarbonBenchmark).where(CarbonBenchmark.year == year)
|
||||
)
|
||||
benchmarks = benchmarks_q.scalars().all()
|
||||
|
||||
year_start = date(year, 1, 1)
|
||||
year_end = date(year, 12, 31)
|
||||
scope1 = await calculate_scope1_emission(db, year_start, year_end)
|
||||
scope2 = await calculate_scope2_emission(db, year_start, year_end)
|
||||
total = scope1 + scope2
|
||||
reduction = await calculate_total_reduction(db, year_start, year_end)
|
||||
|
||||
comparisons = []
|
||||
for b in benchmarks:
|
||||
comparisons.append({
|
||||
"industry": b.industry,
|
||||
"metric": b.metric_name,
|
||||
"benchmark_value": b.benchmark_value,
|
||||
"unit": b.unit,
|
||||
"source": b.source,
|
||||
})
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"actual_emission_tons": round(total, 4),
|
||||
"actual_reduction_tons": round(reduction, 4),
|
||||
"net_tons": round(total - reduction, 4),
|
||||
"benchmarks": comparisons,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_carbon_dashboard(db: AsyncSession) -> dict:
|
||||
"""Comprehensive carbon dashboard data."""
|
||||
now = datetime.now(timezone.utc)
|
||||
year = now.year
|
||||
year_start = date(year, 1, 1)
|
||||
today = now.date()
|
||||
|
||||
scope1 = await calculate_scope1_emission(db, year_start, today)
|
||||
scope2 = await calculate_scope2_emission(db, year_start, today)
|
||||
total_emission = scope1 + scope2
|
||||
reduction = await calculate_total_reduction(db, year_start, today)
|
||||
net = total_emission - reduction
|
||||
green_rate = round((reduction / total_emission * 100) if total_emission > 0 else 0, 1)
|
||||
|
||||
# Target progress
|
||||
target_progress = await get_target_progress(db, year)
|
||||
|
||||
# Monthly trend
|
||||
monthly = await _monthly_breakdown(db, year_start, today)
|
||||
|
||||
# Reduction summary
|
||||
reduction_summary = await get_reduction_summary(db, year_start, today)
|
||||
|
||||
# Certificate value
|
||||
cert_value = await get_certificate_portfolio_value(db)
|
||||
|
||||
return {
|
||||
"kpi": {
|
||||
"total_emission_tons": round(total_emission, 4),
|
||||
"total_reduction_tons": round(reduction, 4),
|
||||
"net_emission_tons": round(net, 4),
|
||||
"green_rate": green_rate,
|
||||
"scope1_tons": scope1,
|
||||
"scope2_tons": scope2,
|
||||
"equivalent_trees": round(reduction / TREE_ABSORPTION, 0) if reduction > 0 else 0,
|
||||
},
|
||||
"target_progress": target_progress.get("annual_target"),
|
||||
"monthly_trend": monthly,
|
||||
"reduction_by_source": reduction_summary,
|
||||
"certificate_portfolio": cert_value,
|
||||
}
|
||||
Reference in New Issue
Block a user