Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
475313855d |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"project": "ems-core",
|
"project": "ems-core",
|
||||||
"project_version": "1.3.0",
|
"project_version": "1.4.0",
|
||||||
"last_updated": "2026-04-06",
|
"last_updated": "2026-04-06",
|
||||||
"notes": "Generic defaults, dashboard energy fallback, PV device type filter fix"
|
"notes": "Version API, solar KPI endpoint (PR, equiv hours, revenue, self-consumption)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding
|
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding, version, kpi
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -26,3 +26,5 @@ api_router.include_router(energy_strategy.router)
|
|||||||
api_router.include_router(weather.router)
|
api_router.include_router(weather.router)
|
||||||
api_router.include_router(ai_ops.router)
|
api_router.include_router(ai_ops.router)
|
||||||
api_router.include_router(branding.router)
|
api_router.include_router(branding.router)
|
||||||
|
api_router.include_router(version.router)
|
||||||
|
api_router.include_router(kpi.router)
|
||||||
|
|||||||
93
backend/app/api/v1/kpi.py
Normal file
93
backend/app/api/v1/kpi.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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 PV device for today
|
||||||
|
daily_gen_q = await db.execute(
|
||||||
|
select(
|
||||||
|
EnergyData.device_id,
|
||||||
|
func.max(EnergyData.value).label("max_energy"),
|
||||||
|
).where(
|
||||||
|
and_(
|
||||||
|
EnergyData.timestamp >= today_start,
|
||||||
|
EnergyData.data_type == "daily_energy",
|
||||||
|
EnergyData.device_id.in_(pv_ids),
|
||||||
|
)
|
||||||
|
).group_by(EnergyData.device_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if values are station-level (all identical) or device-level
|
||||||
|
daily_values = daily_gen_q.all()
|
||||||
|
if not daily_values:
|
||||||
|
daily_generation_kwh = 0
|
||||||
|
else:
|
||||||
|
values = [row[1] or 0 for row in daily_values]
|
||||||
|
# If all values are identical, it's station-level data — use max (not sum)
|
||||||
|
if len(set(values)) == 1 and len(values) > 1:
|
||||||
|
daily_generation_kwh = values[0]
|
||||||
|
else:
|
||||||
|
daily_generation_kwh = sum(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),
|
||||||
|
}
|
||||||
32
backend/app/api/v1/version.py
Normal file
32
backend/app/api/v1/version.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/version", tags=["版本信息"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_version():
|
||||||
|
"""Return platform version information for display on login/dashboard"""
|
||||||
|
# Read VERSIONS.json from project root (2 levels up from backend/)
|
||||||
|
backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# Try multiple paths for VERSIONS.json
|
||||||
|
for path in [
|
||||||
|
os.path.join(backend_dir, "VERSIONS.json"), # standalone
|
||||||
|
os.path.join(backend_dir, "..", "VERSIONS.json"), # inside core/ subtree
|
||||||
|
os.path.join(backend_dir, "..", "..", "VERSIONS.json"), # customer project root
|
||||||
|
]:
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
versions = json.load(f)
|
||||||
|
return versions
|
||||||
|
|
||||||
|
# Fallback: read VERSION file
|
||||||
|
version_file = os.path.join(backend_dir, "VERSION")
|
||||||
|
version = "unknown"
|
||||||
|
if os.path.exists(version_file):
|
||||||
|
with open(version_file, 'r') as f:
|
||||||
|
version = f.read().strip()
|
||||||
|
|
||||||
|
return {"project_version": version, "project": "ems-core"}
|
||||||
Reference in New Issue
Block a user