diff --git a/VERSION b/VERSION index f0bb29e..88c5fb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.4.0 diff --git a/VERSIONS.json b/VERSIONS.json index f0741b2..2568f43 100644 --- a/VERSIONS.json +++ b/VERSIONS.json @@ -1,6 +1,6 @@ { "project": "ems-core", - "project_version": "1.3.0", + "project_version": "1.4.0", "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)" } diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 366f61b..5d6ab5f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,5 @@ 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") @@ -26,3 +26,5 @@ api_router.include_router(energy_strategy.router) api_router.include_router(weather.router) api_router.include_router(ai_ops.router) api_router.include_router(branding.router) +api_router.include_router(version.router) +api_router.include_router(kpi.router) diff --git a/backend/app/api/v1/kpi.py b/backend/app/api/v1/kpi.py new file mode 100644 index 0000000..1bd4522 --- /dev/null +++ b/backend/app/api/v1/kpi.py @@ -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), + } diff --git a/backend/app/api/v1/version.py b/backend/app/api/v1/version.py new file mode 100644 index 0000000..005dc17 --- /dev/null +++ b/backend/app/api/v1/version.py @@ -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"}