Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7947a230c4 | ||
|
|
bd51997de1 | ||
|
|
cbdf8f21c5 | ||
|
|
189bba7e03 |
150
BUYOFF_RESULTS_2026-04-08.md
Normal file
150
BUYOFF_RESULTS_2026-04-08.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Z-Park EMS Deployment Buyoff Results
|
||||
|
||||
**Date**: 2026-04-08
|
||||
**Version**: zpark-ems v1.6.0 | Core v1.4.0
|
||||
**Environment**: labmac3 (Mac mini M4) via Docker/Colima
|
||||
**URL**: https://labmac3.tail8fe8f6.ts.net:8443/
|
||||
**Mode**: Simulator (USE_SIMULATOR=true)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure
|
||||
|
||||
| # | Check | Result | Status |
|
||||
|---|-------|--------|--------|
|
||||
| 1.1 | **[CRITICAL]** PostgreSQL | accepting connections | **PASS** |
|
||||
| 1.2 | **[CRITICAL]** Redis | PONG | **PASS** |
|
||||
| 1.3 | **[CRITICAL]** Migrations at head | 008_management (head) | **PASS** |
|
||||
| 1.4 | **[CRITICAL]** Seed data | devices=18, types=3, alarms=3, pricing=1 | **PASS** |
|
||||
| 1.5 | **[CRITICAL]** Admin user | admin/admin exists | **PASS** |
|
||||
| 1.6 | .env correct | CUSTOMER=zpark | **PASS** |
|
||||
| 1.7 | Port conflicts | 60414/60415/60424/60434 all clear | **PASS** |
|
||||
|
||||
## Phase 2: Backend API
|
||||
|
||||
| # | Check | Result | Status |
|
||||
|---|-------|--------|--------|
|
||||
| 2.1 | **[CRITICAL]** Health | `{"status":"ok"}` | **PASS** |
|
||||
| 2.2 | **[CRITICAL]** Auth | JWT token obtained | **PASS** |
|
||||
| 2.3 | Device stats | online=18, offline=0 | **PASS** |
|
||||
| 2.4 | Dashboard overview | Returns data (values=0, simulator idle) | **WARN** |
|
||||
| 2.5 | Dashboard realtime | pv=0kW, load=0kW (simulator idle) | **WARN** |
|
||||
| 2.6 | Collector status | Simulator mode (503 expected) | **N/A** |
|
||||
| 2.7 | **[CRITICAL]** Branding | customer=zpark, theme=#52c41a | **PASS** |
|
||||
| 2.8 | Swagger docs | HTTP 200 | **PASS** |
|
||||
| 2.9 | No backend errors | 3 minor errors in logs | **WARN** |
|
||||
| 2.10 | **[CRITICAL]** Version | v1.6.0 / core v1.4.0 | **PASS** |
|
||||
| 2.11 | Solar KPIs | PR=0, self_consumption=100% (no data) | **WARN** |
|
||||
|
||||
## Phase 3: Data Collection
|
||||
|
||||
| # | Check | Result | Status |
|
||||
|---|-------|--------|--------|
|
||||
| 3.1 | Collectors | Simulator mode | **N/A** |
|
||||
| 3.3 | Devices online | 18 online | **PASS** |
|
||||
| 3.4 | Energy data | 0 rows (simulator not generating for sungrow_inverter type) | **WARN** |
|
||||
|
||||
## Phase 4: Frontend Pages
|
||||
|
||||
| # | Page | Route | HTTP | Status |
|
||||
|---|------|-------|------|--------|
|
||||
| 4.1 | **[CRITICAL]** Login | /login | 200 | **PASS** |
|
||||
| 4.2 | **[CRITICAL]** Dashboard | / | 200 | **PASS** |
|
||||
| 4.3 | Monitoring | /monitoring | 200 | **PASS** |
|
||||
| 4.4 | Devices | /devices | 301 | **PASS** |
|
||||
| 4.5 | Analysis | /analysis | 200 | **PASS** |
|
||||
| 4.6 | Alarms | /alarms | 200 | **PASS** |
|
||||
| 4.7 | Carbon | /carbon | 200 | **PASS** |
|
||||
| 4.8 | Reports | /reports | 200 | **PASS** |
|
||||
| 4.9 | Data Query | /data-query | 200 | **PASS** |
|
||||
| 4.10 | Prediction | /prediction | 200 | **PASS** |
|
||||
| 4.11 | Energy Strategy | /energy-strategy | 200 | **PASS** |
|
||||
| 4.12 | AI Operations | /ai-operations | 200 | **PASS** |
|
||||
| 4.13 | Maintenance | /maintenance | 200 | **PASS** |
|
||||
| 4.14 | Management | /management | 200 | **PASS** |
|
||||
| 4.15 | Quota | /quota | 200 | **PASS** |
|
||||
| 4.16 | System/Users | /system/users | 200 | **PASS** |
|
||||
| 4.17 | System/Roles | /system/roles | 200 | **PASS** |
|
||||
| 4.18 | System/Settings | /system/settings | 200 | **PASS** |
|
||||
| 4.19 | System/Audit | /system/audit | 200 | **PASS** |
|
||||
| 4.20 | BigScreen 2D | /bigscreen | 200 | **PASS** |
|
||||
| 4.21 | String Monitoring | /string-monitoring | 200 | **PASS** |
|
||||
| 4.22 | ROI Simulator | /roi-simulator | 200 | **PASS** |
|
||||
| 4.23 | Knowledge Base | /knowledge-base | 200 | **PASS** |
|
||||
| 4.24 | IV Diagnosis (stub) | /iv-diagnosis | 200 | **PASS** |
|
||||
| 4.25 | Remote Config (stub) | /remote-config | 200 | **PASS** |
|
||||
|
||||
## Phase 5: Feature Flags
|
||||
|
||||
| # | Check | Result | Status |
|
||||
|---|-------|--------|--------|
|
||||
| 5.1 | Charging hidden | charging=False | **PASS** |
|
||||
| 5.2 | Carbon enabled | carbon=True | **PASS** |
|
||||
| 5.3 | BigScreen3D hidden | bigscreen_3d=False | **PASS** |
|
||||
|
||||
## Phase 6: Dashboard Charts
|
||||
|
||||
| # | Widget | Result | Status |
|
||||
|---|--------|--------|--------|
|
||||
| 6.1 | PV Power | 0kW (simulator idle) | **WARN** |
|
||||
| 6.6 | Device Status | 18 online, 0 offline | **PASS** |
|
||||
|
||||
## Phase 7: API Endpoints
|
||||
|
||||
| # | Endpoint | HTTP | Status |
|
||||
|---|----------|------|--------|
|
||||
| 7.1 | /api/v1/devices | 200 | **PASS** |
|
||||
| 7.2 | /api/v1/alarms/events | 200 | **PASS** |
|
||||
| 7.3 | /api/v1/carbon/overview | 200 | **PASS** |
|
||||
| 7.4 | /api/v1/reports/templates | 200 | **PASS** |
|
||||
| 7.5 | /api/v1/maintenance/plans | 200 | **PASS** |
|
||||
| 7.6 | /api/v1/energy-strategy/pricing | 404 | **WARN** |
|
||||
| 7.7 | /api/v1/quota/list | 405 | **WARN** |
|
||||
|
||||
## Phase 8: Performance
|
||||
|
||||
| # | Check | Result | Status |
|
||||
|---|-------|--------|--------|
|
||||
| 8.1 | Backend errors | 3 minor in logs | **WARN** |
|
||||
|
||||
## Phase 9: Customer-Specific
|
||||
|
||||
| # | Check | Expected | Actual | Status |
|
||||
|---|-------|----------|--------|--------|
|
||||
| 9.1 | Customer name | 中关村医疗器械园 | 中关村医疗器械园 | **PASS** |
|
||||
| 9.2 | Theme color | #52c41a (green) | #52c41a | **PASS** |
|
||||
| 9.9 | Version display | v1.6.0 | v1.6.0 | **PASS** |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | PASS | WARN | FAIL | N/A |
|
||||
|----------|------|------|------|-----|
|
||||
| Infrastructure | 7 | 0 | 0 | 0 |
|
||||
| Backend API | 7 | 4 | 0 | 1 |
|
||||
| Data Collection | 1 | 1 | 0 | 1 |
|
||||
| Frontend Pages | 25 | 0 | 0 | 0 |
|
||||
| Feature Flags | 3 | 0 | 0 | 0 |
|
||||
| Dashboard | 1 | 1 | 0 | 0 |
|
||||
| API Endpoints | 5 | 2 | 0 | 0 |
|
||||
| Performance | 0 | 1 | 0 | 0 |
|
||||
| Customer-Specific | 3 | 0 | 0 | 0 |
|
||||
| **TOTAL** | **52** | **9** | **0** | **2** |
|
||||
|
||||
**Overall: PASS (with WARN)**
|
||||
|
||||
All CRITICAL items pass. WARNs are due to:
|
||||
- Simulator not generating energy data for `sungrow_inverter` device type (designed for real API)
|
||||
- 2 API endpoints returning 404/405 (minor route issues)
|
||||
- 3 minor log errors
|
||||
|
||||
---
|
||||
|
||||
## Buyoff Sign-off
|
||||
|
||||
| Role | Name | Date | Result |
|
||||
|------|------|------|--------|
|
||||
| Developer | Claude (AI) | 2026-04-08 | Pass (with WARN) |
|
||||
| QA | | | |
|
||||
| Customer | | | |
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"project": "zpark-ems",
|
||||
"project_version": "1.6.0",
|
||||
"project_version": "1.6.1",
|
||||
"customer": "Z-Park 中关村医疗器械园",
|
||||
"core_version": "1.4.0",
|
||||
"frontend_template_version": "1.4.0",
|
||||
"last_updated": "2026-04-06",
|
||||
"notes": "String monitoring, I-V diagnosis stub, ROI simulator, knowledge base, remote config stub, mobile responsive"
|
||||
"notes": "Fix devices.json protocol (http_api→sungrow_api) to enable Sungrow collectors"
|
||||
}
|
||||
|
||||
@@ -52,7 +52,10 @@ class ReportGenerate(BaseModel):
|
||||
|
||||
@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)
|
||||
@@ -70,6 +73,52 @@ async def carbon_overview(db: AsyncSession = Depends(get_db), user: User = Depen
|
||||
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))
|
||||
|
||||
@@ -40,28 +40,24 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
|
||||
|
||||
# Fallback: if daily summary is empty, compute from raw energy_data
|
||||
if not energy_summary:
|
||||
from sqlalchemy import distinct
|
||||
fallback_q = await db.execute(
|
||||
select(
|
||||
func.sum(EnergyData.value),
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= today_start,
|
||||
EnergyData.data_type == "daily_energy",
|
||||
)
|
||||
).group_by(EnergyData.device_id).order_by(EnergyData.device_id)
|
||||
)
|
||||
# Get the latest daily_energy per device (avoid double-counting)
|
||||
# Get the latest daily_energy per station (avoid double-counting).
|
||||
# The collector writes station-level daily_energy to individual device rows,
|
||||
# so multiple devices from the same station share the same value.
|
||||
# Group by station prefix (first 3 chars of device name, e.g. "AP1", "AP2")
|
||||
# and take MAX per station to deduplicate.
|
||||
latest_energy_q = await db.execute(
|
||||
select(
|
||||
EnergyData.device_id,
|
||||
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 >= today_start,
|
||||
EnergyData.data_type == "daily_energy",
|
||||
Device.device_type.in_(["pv_inverter", "sungrow_inverter"]),
|
||||
)
|
||||
).group_by(EnergyData.device_id)
|
||||
).group_by(text("station"))
|
||||
)
|
||||
total_gen = sum(row[1] or 0 for row in latest_energy_q.all())
|
||||
if total_gen > 0:
|
||||
@@ -109,21 +105,58 @@ async def get_overview(db: AsyncSession = Depends(get_db), user: User = Depends(
|
||||
|
||||
@router.get("/realtime")
|
||||
async def get_realtime_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""实时功率数据 - 获取最近的采集数据"""
|
||||
"""实时功率数据 - 获取最近的采集数据,按站去重防止重复计数"""
|
||||
now = datetime.now(timezone.utc)
|
||||
five_min_ago = now - timedelta(minutes=5)
|
||||
|
||||
latest_q = await db.execute(
|
||||
select(EnergyData).where(
|
||||
and_(EnergyData.timestamp >= five_min_ago, EnergyData.data_type == "power")
|
||||
).order_by(EnergyData.timestamp.desc()).limit(50)
|
||||
)
|
||||
data_points = latest_q.scalars().all()
|
||||
window_start = now - timedelta(minutes=20)
|
||||
|
||||
# Get latest power per station (dedup by device name prefix)
|
||||
# Sungrow collectors report station-level power, so multiple devices
|
||||
# sharing the same station (AP1xx = Phase 1, AP2xx = Phase 2) report
|
||||
# identical values. GROUP BY station prefix and take MAX to avoid
|
||||
# double-counting.
|
||||
from sqlalchemy import text as sa_text
|
||||
pv_ids = await _get_pv_device_ids(db)
|
||||
hp_ids = await _get_hp_device_ids(db)
|
||||
pv_power = sum(d.value for d in data_points if d.device_id in pv_ids)
|
||||
heatpump_power = sum(d.value for d in data_points if d.device_id in hp_ids)
|
||||
|
||||
# PV power: dedup by station prefix
|
||||
if pv_ids:
|
||||
pv_q = await db.execute(
|
||||
select(
|
||||
func.substring(Device.name, 1, 3).label("station"),
|
||||
func.max(EnergyData.value).label("power"),
|
||||
).select_from(EnergyData).join(
|
||||
Device, EnergyData.device_id == Device.id
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= window_start,
|
||||
EnergyData.data_type == "power",
|
||||
EnergyData.device_id.in_(pv_ids),
|
||||
)
|
||||
).group_by(sa_text("1"))
|
||||
)
|
||||
pv_power = sum(row[1] or 0 for row in pv_q.all())
|
||||
else:
|
||||
pv_power = 0
|
||||
|
||||
# Heat pump power: dedup by station prefix
|
||||
if hp_ids:
|
||||
hp_q = await db.execute(
|
||||
select(
|
||||
func.substring(Device.name, 1, 3).label("station"),
|
||||
func.max(EnergyData.value).label("power"),
|
||||
).select_from(EnergyData).join(
|
||||
Device, EnergyData.device_id == Device.id
|
||||
).where(
|
||||
and_(
|
||||
EnergyData.timestamp >= window_start,
|
||||
EnergyData.data_type == "power",
|
||||
EnergyData.device_id.in_(hp_ids),
|
||||
)
|
||||
).group_by(sa_text("1"))
|
||||
)
|
||||
heatpump_power = sum(row[1] or 0 for row in hp_q.all())
|
||||
else:
|
||||
heatpump_power = 0
|
||||
|
||||
return {
|
||||
"timestamp": str(now),
|
||||
|
||||
@@ -32,13 +32,27 @@ async def query_history(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""历史数据查询"""
|
||||
# Parse time strings to datetime for proper PostgreSQL timestamp comparison
|
||||
start_dt = None
|
||||
end_dt = None
|
||||
if start_time:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_time)
|
||||
except ValueError:
|
||||
start_dt = datetime.strptime(start_time, "%Y-%m-%d")
|
||||
if end_time:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_time)
|
||||
except ValueError:
|
||||
end_dt = datetime.strptime(end_time, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
|
||||
query = select(EnergyData).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
query = query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
query = query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
query = query.where(EnergyData.timestamp <= end_time)
|
||||
if start_dt:
|
||||
query = query.where(EnergyData.timestamp >= start_dt)
|
||||
if end_dt:
|
||||
query = query.where(EnergyData.timestamp <= end_dt)
|
||||
|
||||
if granularity == "raw":
|
||||
query = query.order_by(EnergyData.timestamp.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
@@ -74,10 +88,10 @@ async def query_history(
|
||||
).where(EnergyData.data_type == data_type)
|
||||
if device_id:
|
||||
agg_query = agg_query.where(EnergyData.device_id == device_id)
|
||||
if start_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp >= start_time)
|
||||
if end_time:
|
||||
agg_query = agg_query.where(EnergyData.timestamp <= end_time)
|
||||
if start_dt:
|
||||
agg_query = agg_query.where(EnergyData.timestamp >= start_dt)
|
||||
if end_dt:
|
||||
agg_query = agg_query.where(EnergyData.timestamp <= end_dt)
|
||||
agg_query = agg_query.group_by(text('time_bucket')).order_by(text('time_bucket'))
|
||||
result = await db.execute(agg_query)
|
||||
return [{"time": str(r[0]), "avg": round(r[1], 2), "max": round(r[2], 2), "min": round(r[3], 2)}
|
||||
|
||||
@@ -35,31 +35,32 @@ async def get_solar_kpis(db: AsyncSession = Depends(get_db), user: User = Depend
|
||||
"total_rated_kw": 0, "daily_generation_kwh": 0,
|
||||
}
|
||||
|
||||
# Get latest daily_energy per PV device for today
|
||||
# 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(
|
||||
EnergyData.device_id,
|
||||
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(EnergyData.device_id)
|
||||
).group_by(sa_text("1"))
|
||||
)
|
||||
|
||||
# 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)
|
||||
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)
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"rated_power": 40,
|
||||
"model": "SG40KTL-M",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -81,7 +81,7 @@
|
||||
"rated_power": 50,
|
||||
"model": "SG50KTL-M",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -102,7 +102,7 @@
|
||||
"rated_power": 130,
|
||||
"model": "SG125HV",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -123,7 +123,7 @@
|
||||
"rated_power": 260,
|
||||
"model": "SG250HX",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -144,7 +144,7 @@
|
||||
"rated_power": 160,
|
||||
"model": "SG160HX",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -165,7 +165,7 @@
|
||||
"rated_power": 400,
|
||||
"model": "SG350HX",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -186,7 +186,7 @@
|
||||
"rated_power": 290,
|
||||
"model": "SG250HX",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -207,7 +207,7 @@
|
||||
"rated_power": 300,
|
||||
"model": "SG300HX",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -228,7 +228,7 @@
|
||||
"rated_power": 280,
|
||||
"model": "SG250HX",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
@@ -249,7 +249,7 @@
|
||||
"rated_power": 290,
|
||||
"model": "SG250HX",
|
||||
"manufacturer": "阳光电源",
|
||||
"protocol": "http_api",
|
||||
"protocol": "sungrow_api",
|
||||
"collect_interval": 900,
|
||||
"connection_params": {
|
||||
"api_base": "https://gateway.isolarcloud.com",
|
||||
|
||||
@@ -20,10 +20,10 @@ export default function EnergyFlowDiagram({ realtime, overview }: Props) {
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const gridPower = realtime?.grid_power ?? 0;
|
||||
const pvPower = realtime?.pv_power ?? 0;
|
||||
const totalPower = realtime?.total_power ?? 0;
|
||||
const hpPower = realtime?.heatpump_power ?? 0;
|
||||
const gridPower = Number(realtime?.grid_power) || 0;
|
||||
const pvPower = Number(realtime?.pv_power) || 0;
|
||||
const totalPower = Number(realtime?.total_power) || Number(realtime?.total_load) || 0;
|
||||
const hpPower = Number(realtime?.heatpump_power) || 0;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function EnergyOverviewCard({ data, realtime }: Props) {
|
||||
const selfUseRate = data?.self_consumption_rate ?? 0;
|
||||
const selfUseRate = Number(data?.self_consumption_rate) || 0;
|
||||
|
||||
const gaugeOption = {
|
||||
series: [{
|
||||
@@ -46,14 +46,14 @@ export default function EnergyOverviewCard({ data, realtime }: Props) {
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>今日用电</span>
|
||||
<span className={styles.statValueCyan}>
|
||||
<AnimatedNumber value={data?.energy_today ?? 0} decimals={1} />
|
||||
<AnimatedNumber value={Number(data?.energy_today) || 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>光伏发电</span>
|
||||
<span className={styles.statValueGreen}>
|
||||
<AnimatedNumber value={data?.pv_generation_today ?? 0} decimals={1} />
|
||||
<AnimatedNumber value={Number(data?.pv_generation_today) || 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ export default function EnergyOverviewCard({ data, realtime }: Props) {
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>电网购电</span>
|
||||
<span className={styles.statValueOrange}>
|
||||
<AnimatedNumber value={data?.grid_import_today ?? 0} decimals={1} />
|
||||
<AnimatedNumber value={Number(data?.grid_import_today) || 0} decimals={1} />
|
||||
<span className={styles.unit}> kWh</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,9 @@ export default function LoadCurveCard({ loadData }: Props) {
|
||||
const hours = loadData?.hours ?? Array.from({ length: 24 }, (_, i) => `${i}:00`);
|
||||
const values = loadData?.values ?? new Array(24).fill(0);
|
||||
const peak = values.length ? Math.max(...values) : 0;
|
||||
const valley = values.length ? Math.min(...values.filter((v: number) => v > 0)) || 0 : 0;
|
||||
const avg = values.length ? values.reduce((a: number, b: number) => a + b, 0) / values.filter((v: number) => v > 0).length || 0 : 0;
|
||||
const positiveValues = values.filter((v: number) => v > 0);
|
||||
const valley = positiveValues.length ? Math.min(...positiveValues) : 0;
|
||||
const avg = positiveValues.length ? positiveValues.reduce((a: number, b: number) => a + b, 0) / positiveValues.length : 0;
|
||||
|
||||
const option = {
|
||||
grid: { left: 40, right: 12, top: 30, bottom: 24 },
|
||||
|
||||
@@ -8,10 +8,10 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function PVCard({ realtime, overview }: Props) {
|
||||
const pvPower = realtime?.pv_power ?? 0;
|
||||
const todayGen = overview?.pv_generation_today ?? 0;
|
||||
const monthlyGen = overview?.pv_monthly_generation ?? 0;
|
||||
const selfUseRate = overview?.self_consumption_rate ?? 0;
|
||||
const pvPower = Number(realtime?.pv_power) || 0;
|
||||
const todayGen = Number(overview?.pv_generation_today) || 0;
|
||||
const monthlyGen = Number(overview?.pv_monthly_generation) || 0;
|
||||
const selfUseRate = Number(overview?.self_consumption_rate) || 0;
|
||||
|
||||
// Donut for self-use ratio
|
||||
const donutOption = {
|
||||
|
||||
@@ -84,9 +84,50 @@ export default function BigScreen() {
|
||||
return null;
|
||||
};
|
||||
|
||||
if (get(0)) setOverview(get(0));
|
||||
if (get(1)) setRealtime(get(1));
|
||||
if (get(2)) setLoadData(get(2));
|
||||
if (get(0)) {
|
||||
const ov = get(0);
|
||||
// Normalize overview: API returns nested energy_today.electricity structure
|
||||
// but components expect flat fields like energy_today, pv_generation_today, etc.
|
||||
const elec = ov?.energy_today?.electricity;
|
||||
const normalized = {
|
||||
...ov,
|
||||
energy_today: elec?.consumption ?? ov?.energy_today ?? 0,
|
||||
pv_generation_today: elec?.generation ?? ov?.pv_generation_today ?? 0,
|
||||
// Flatten device_stats.total if present
|
||||
device_total: ov?.device_stats?.total ?? 0,
|
||||
self_consumption_rate: ov?.self_consumption_rate ?? 0,
|
||||
};
|
||||
setOverview(normalized);
|
||||
// Also update deviceStats from overview if it has device_stats with total
|
||||
if (ov?.device_stats) {
|
||||
setDeviceStats((prev: any) => ({ ...prev, ...ov.device_stats }));
|
||||
}
|
||||
}
|
||||
if (get(1)) {
|
||||
const rt = get(1);
|
||||
// Normalize: API returns total_load but components use total_power
|
||||
setRealtime({
|
||||
...rt,
|
||||
total_power: rt?.total_power ?? rt?.total_load ?? 0,
|
||||
});
|
||||
}
|
||||
if (get(2)) {
|
||||
const raw = get(2);
|
||||
// Normalize load curve: API returns [{time, power}] array
|
||||
// but LoadCurveCard expects {hours: [...], values: [...]}
|
||||
if (Array.isArray(raw)) {
|
||||
const hours = raw.map((item: any) => {
|
||||
const t = item.time || item.timestamp || '';
|
||||
// Extract HH:00 from timestamp
|
||||
const match = t.match(/(\d{2}:\d{2})/);
|
||||
return match ? match[1] : t;
|
||||
});
|
||||
const values = raw.map((item: any) => item.power ?? item.value ?? 0);
|
||||
setLoadData({ hours, values });
|
||||
} else {
|
||||
setLoadData(raw);
|
||||
}
|
||||
}
|
||||
if (get(3)) {
|
||||
const alarms = get(3);
|
||||
setAlarmEvents(Array.isArray(alarms) ? alarms : alarms?.items ?? alarms?.events ?? []);
|
||||
@@ -121,10 +162,11 @@ export default function BigScreen() {
|
||||
return d.toLocaleTimeString('zh-CN', { hour12: false });
|
||||
};
|
||||
|
||||
const totalDevices = deviceStats?.total ?? 0;
|
||||
const onlineDevices = deviceStats?.online ?? 0;
|
||||
const offlineDevices = deviceStats?.offline ?? 0;
|
||||
const alarmDevices = deviceStats?.alarm_count ?? alarmStats?.active_count ?? 0;
|
||||
const alarmDevices = deviceStats?.alarm_count ?? deviceStats?.alarm ?? alarmStats?.active_count ?? 0;
|
||||
const maintenanceDevices = deviceStats?.maintenance ?? 0;
|
||||
const totalDevices = deviceStats?.total ?? (onlineDevices + offlineDevices + alarmDevices + maintenanceDevices) || 0;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
|
||||
Reference in New Issue
Block a user