Files
ems-core/backend/app/api/v1/kpi.py
Du Wenbo a05b25bcc2 fix: realtime + KPI power dedup by station prefix (v1.4.3)
Realtime endpoint was summing ALL device power readings, causing
double-counting when multiple devices share the same Sungrow station.
E.g. 10 devices × station-level power = 5x inflated total.

Fix: GROUP BY station prefix (first 3 chars of device name) and
take MAX per station. Same fix applied to KPI daily_generation.

Result: 5,550 kW → 1,931 kW (matches iSolarCloud's 2,049 kW
within the 15-min collection timing window).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:18:49 +08:00

95 lines
3.8 KiB
Python

from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.device import Device
from app.models.energy import EnergyData
from app.models.user import User
router = APIRouter(prefix="/kpi", tags=["关键指标"])
@router.get("/solar")
async def get_solar_kpis(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""Solar performance KPIs - PR, self-consumption, equivalent hours, revenue"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Get PV devices and their rated power
pv_q = await db.execute(
select(Device.id, Device.rated_power).where(
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
Device.is_active == True,
)
)
pv_devices = pv_q.all()
pv_ids = [d[0] for d in pv_devices]
total_rated_kw = sum(d[1] or 0 for d in pv_devices) # kW
if not pv_ids or total_rated_kw == 0:
return {
"pr": 0, "self_consumption_rate": 0,
"equivalent_hours": 0, "revenue_today": 0,
"total_rated_kw": 0, "daily_generation_kwh": 0,
}
# Get latest daily_energy per station (dedup by device name prefix)
# Sungrow collectors report station-level data per device, so multiple
# devices sharing the same station report identical values.
# Group by station prefix (first 3 chars of name, e.g. "AP1" vs "AP2")
# and take MAX per station to avoid double-counting.
from sqlalchemy import text as sa_text
daily_gen_q = await db.execute(
select(
func.substring(Device.name, 1, 3).label("station"),
func.max(EnergyData.value).label("max_energy"),
).select_from(EnergyData).join(
Device, EnergyData.device_id == Device.id
).where(
and_(
EnergyData.timestamp >= today_start,
EnergyData.data_type == "daily_energy",
EnergyData.device_id.in_(pv_ids),
)
).group_by(sa_text("1"))
)
daily_values = daily_gen_q.all()
if not daily_values:
daily_generation_kwh = 0
else:
daily_generation_kwh = sum(row[1] or 0 for row in daily_values)
# Performance Ratio (PR) = actual output / (rated capacity * peak sun hours)
# Approximate peak sun hours from time of day (simplified)
hours_since_sunrise = max(0, min(12, (now.hour + now.minute / 60) - 6)) # approx 6am sunrise
theoretical_kwh = total_rated_kw * hours_since_sunrise * 0.8 # 0.8 = typical irradiance factor
pr = (daily_generation_kwh / theoretical_kwh * 100) if theoretical_kwh > 0 else 0
pr = min(100, round(pr, 1)) # Cap at 100%
# Self-consumption rate (without grid export meter, assume 100% self-consumed for now)
# TODO: integrate grid export meter data when available
self_consumption_rate = 100.0
# Equivalent utilization hours = daily generation / rated capacity
equivalent_hours = round(daily_generation_kwh / total_rated_kw, 2) if total_rated_kw > 0 else 0
# Revenue = daily generation * electricity price
# TODO: get actual price from electricity_pricing table
# Default industrial TOU average price in Beijing: ~0.65 CNY/kWh
avg_price = 0.65
revenue_today = round(daily_generation_kwh * avg_price, 2)
return {
"pr": pr,
"self_consumption_rate": round(self_consumption_rate, 1),
"equivalent_hours": equivalent_hours,
"revenue_today": revenue_today,
"total_rated_kw": total_rated_kw,
"daily_generation_kwh": round(daily_generation_kwh, 2),
"avg_price_per_kwh": avg_price,
"pv_device_count": len(pv_ids),
}